# Changelog Notable changes, newest first. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Two version numbers are deliberately not this one, because they change on their own schedule and breaking either is a different kind of event: - **The IPC protocol** is versioned inside the pipe name (`shubbak-v2-`), so a new CLI and an old daemon fail to find each other rather than misunderstand each other. - **The session format** is versioned in the file, so an unreadable session is discarded rather than misread. ## [Unreleased] ### Added - **A palette row can follow a context.** `when-context="focusing"` on an `action` offers the row only while that context holds; `unless-context="focusing"` only while it does not. The reason is a pair of rows that are really one switch: "Start focus timer" and "Stop focus timer" are two actions, and only ever one of them makes sense - the one the timer is not already doing - yet the list showed both, every time, and left the reader to remember which. The program behind such a pair is driven by signals and has no row of its own to edit, so the condition is how its state reaches the palette without the program knowing the palette exists: it holds a context, as the focus timer in `examples/` already did for the keys and the borders and as the watcher does for the microphone, and the rows follow. The command list shows the half that applies and keeps the other back, changing as the context does - the palette already re-read the world on `context.changed`. A row kept back is not gone: the palette's own `actions` list, the row that says how many you have written, shows every one, the withheld ones greyed with the condition beside them (`not now` - `only while context 'focusing' holds`), so a row you cannot find is one lookup away and says why; and that row now counts the actions written rather than the ones offered, because a configuration whose every row is kept back is exactly the one that needs it. A key bound to `signal "palette" "run" "Start focus timer"` is judged the same way, against what holds at the keystroke rather than at the palette's last reading - which, for a palette closed since the morning, is the morning's - and a row kept back opens that list rather than running or doing nothing. Both keys on one action must hold together. The names are checked against the `contexts` section of the same file, as the bar's rules are: a name nothing declares never holds and is `DAL0021`; one context named by both keys is a row that can never be offered and is `DAL0022`. And a misspelt key on an action - `when-contxt=` - is `DAL0023` rather than, as any unknown property on an action was until now, a setting silently not there; that gap predates the feature and was harmless while `description=` was the only property an action took, and is not harmless for a condition. The example config gains a "Mute the mic" / "Unmute the mic" pair tied to `meeting-live` and `meeting-muted`, and the focus timer's write-up in `docs/extending.md` gains the palette rows that drive it - a listener gets a row by its signal being written down once more with a name on it, which was always so and was never said. - **The bar draws the layout as a picture of itself.** The layout indicator was a box-drawing glyph per layout - `┤` for the spiral, `▌` for master-left - chosen because they need no font installed, and that is all they could be: a character says nothing until it has been learnt, and three spirals and four master layouts are more than a character each can tell apart at a glance. The new `layout` widget draws the workspace sixteen pixels across with a pane for each window the layout would place: the big pane on the left and the rest dwindling into a corner is the spiral, a column of equal strips beside it is the master layout, four squares are the grid, one solid square is monocle. The picture is the window manager's own arithmetic - the bar arranges a handful of placeholder windows with the same `ILayout` code the desktop is arranged by, so a grid of five is three over two with the two stretched, exactly as yours is, and a layout added to the window manager is drawn by the bar the day it exists. Four panes by default, because that is the fewest that tell every layout apart (a spiral of three is precisely a master layout of three, which a test now pins); `panes="windows"` follows the workspace instead, growing a pane as each window opens, which is where the new `{{ windows }}` value comes from - the window manager always sent the count, and the bar always threw it away. Following the workspace literally turned out to defeat the widget: one window is one square in every layout, two windows are two rectangles in most, and the indicator stopped indicating exactly when the workspace was quiet. So it never draws fewer than `min-panes` - four unless said - and the panes the workspace does not yet have are drawn faint, at a third of the colour's opacity: one window in a spiral is the large pane solid and the three it would dwindle into faint, which says the layout and the count at once, the one thing a fixed four cannot. `min-panes=1` is the literal count. `main-colour` picks out the first window's pane, where the main window goes; `when` recolours the panes by the layout's name; the usual gestures make it a control. Underneath, the visual tree gains a `RectShape`, a filled rectangle in the unit square - the one shape every renderer already draws, so unlike a line or an arc it needs no capability and a node made of them is drawn whole by a renderer that has never heard of shapes. The example and starter configs use the widget; `{{ layout | icon }}` is unchanged for a bar that wants text. A `panes` that is neither a count from 1 to 9 nor the word `windows` is `TAJ0040`; a `min-panes` that is not a count is `TAJ0041`, and one written under a fixed count, where it does nothing, is `TAJ0042`. - **The bar draws numbers as well as printing them: a `sparkline` and a `meter`.** A bar that reads `23%` is a figure to read; a bar a quarter full is a shape to see. `meter source="battery"` is a track with a fill along it, sized to where the value sits between `min` and `max` (nought and a hundred unless said) and clamped, so a reading past the end fills the meter rather than spilling out; `shape="ring"` is the same as a gauge, clockwise from the top, and `start=225 sweep=270` makes it a dial. `sparkline source="cpu.history"` draws a list of numbers as a line, oldest at the left, with `colour` for the line, `fill` for a wash under it, and `min`/`max` to pin the scale - which a percentage should, since a CPU graph scaled to its own range makes a quiet minute look like a storm. The list comes from `history=N` on the source it graphs: the source then publishes its last `N` readings as `.history`, repeats included, so a value that holds steady is a flat line and not a line that has stopped - which is why the history is kept beside the source and not in the widget, whose snapshot of values could never tell a new reading from the old one. A script that already keeps a history prints the same shape itself. Both widgets take a `when` block that recolours them by the value, and the same six gestures a text widget does. Underneath, the visual tree gained a `Shape` node - a line through points, an arc - drawn through an `IShapeRenderer` capability beside `IImageRenderer`, so a renderer that cannot stroke a path leaves the shape out and draws the rest. The composited GDI renderer rasterises them itself, anti-aliased, with the same coverage-per-pixel arithmetic the rounded corners use; GDI's own `Polyline` and `Arc` are jagged and write no alpha, and would have punched a hole in a translucent bar where the graph lay. The arithmetic is a pure class under test, down to which pixel a half-pixel line falls on, and it allocates nothing - asserted, as the latency stats are. Measured, before and after, on a fifteen-widget bar that uses none of this: a tick - value set, tree rebuilt, laid out with GDI measuring the text, painted by the composited renderer - is 461 µs before and 464 µs after, the same within the run-to-run noise, allocating 21,320 and 21,432 bytes: the extra 112 is two reference fields on each of 28 nodes less the enumerator the text widget used to box per condition, which it no longer does. Paint allocates nothing in either. The same bar with a sixty-point sparkline, a bar meter and a ring meter added ticks in 517 µs and 23,480 bytes - a sparkline whose history has not changed costs 448 bytes and a tenth of a microsecond to rebuild, since it keeps its plot by the value's reference as the icon widget keeps its bitmap, and a history sample costs the one string that is its value, 384 bytes for sixty readings. The three entries below together add 92 KB to the published `taj.exe` (5,618 KB to 5,709 KB), most of it the number formatting and the trigonometry the binary had no call for before. - **Templates do arithmetic, and `when` compares numbers.** `round`, `round:N`, `add:N`, `sub:N`, `mul:N`, `div:N` and `percent`/`percent:of` read the first number in a value - so `87%` is eighty-seven and `load: 1.75 avg` is one and three quarters - and write a number back, which the template wraps in whatever unit it likes: `{{ mem | div:1073741824 | round:1 }} GB` turns bytes into gigabytes with no script in between. A value with no number in it, or a division by nought, passes through untouched, for the same reason an unknown filter does. `map:a=b,c=d` is the general shape of `icon` and `state-icon`, for a script's `on` and `off` into two glyphs, with `*=` for anything the table does not name. And a `when` block can say `above=80`, `below=10`, or both for a band - strict, on the first number in the drawn text or in the source `of=` names - so "red below ten percent" is a line where it used to need the source to print a word for every band. A value with no number fails a numeric condition: a battery that has not reported yet is not below ten percent. Three new diagnostics say when a `when` names nothing to match (`TAJ0033`), mixes a value with a number (`TAJ0034`), or has a threshold that is not one (`TAJ0035`); the bar's history, source and scale settings get `TAJ0036` to `TAJ0039`. - **A double click, and a `media` verb that presses the keyboard's media keys.** `on-double-click` joins the five gestures. A widget with both it and `on-click` holds the single click for the double-click time Windows is set to and runs it only if no second press arrives, so the two cannot both fire - open the mixer, then also mute; a widget with only `on-click` is not made to wait, and two quick clicks on a workspace are two clicks as they always were. The wait is Windows's own figure, from the mouse settings, and the decision of what a press means is a class in the core with its three outcomes under test. `media play-pause`, `media next`, `media previous`, `media stop`, `media mute`, `media volume-up` and `media volume-down` are the second click command the bar performs itself, after `keyboard`: each presses the media key of that name, down and up in one `SendInput`, and Windows routes it to whichever player is current and to the system volume, with the same flyout the key gets. Pressing the key rather than running a program is what makes one pill serve every player. The keybinding spellings - `media_play_pause`, `volume_up` - are taken too, and a misspelt one is pointed out at load (`TAJ0023`) with the gesture it was written on, as a bad `keyboard` command has been. - **Seven hello-worlds, one per direction the pipe has.** The extending page opened on a 170-line script and a 311-line program, which is the wrong first thing to read; a newcomer wants each feature in ten lines before any of them together. `examples/hello/` is that: `1-ask` (`shubbak query` answers in JSON), `2-tell` (anything a key can do, a command can), `3-listen` (`shubbak sub` streams events; nothing polls), `4-signal` (a key bound to a `signal` the window manager has never heard of reaches your program), `5-bar` (the same `signal` the other way puts a value on the bar and clears it), `6-context` (your program supplies a fact with `--hold`; the config decides what it means; the lease dies with the process) and `7-raw-pipe` (no `shubbak`, no package: a named pipe, three shapes of JSON, a reply read between the events on the one connection). Each carries the one or two config lines it wants in its header; the readme has them together. `Shubbak.Example.Hello` is 1, 3 and 4 again in forty lines of C# on the `Shubbak.Ipc` package, in the solution so CI builds it under the analyzers. Every script was run against a real window manager with nothing, something and no window manager at all in front of it; the focus timer is now the capstone, "all of them at once". A new check, `tools/check-examples.ps1`, puts every example script through PowerShell's parser and resolves every link into `examples/` from the docs, so a typo or a renamed script cannot ship as "the example is broken" - the worst bug there is in the one thing a newcomer tries first - and `build.yml` runs it beside the snippet check. - **A page on extending Shubbak, and a worked example twice over.** The pipe has been the plugin architecture since the bar and the palette were written against it, and the scripting page has been its reference - but nothing showed the shape of a program built on it, and the question "can I write my own widget" had no answer in the FAQ. [`docs/extending.md`](docs/extending.md) is that page: what a program of yours can do over one connection, the three idioms the shipped companions are made of - a provider that supplies a fact as a context, a publisher that puts a value on the bar, a listener that is told what to do by a signal - and what to expect of the window manager on the other end. The example is a focus timer, which nothing shipped does: start it from a key for twenty-five minutes, and while it runs a context holds and the bar counts down. Once in `examples/focus-timer.ps1`, built entirely from `shubbak` commands - `sub signal` for ears, `signal focus mm:ss` for a voice, `context --set focusing --hold` for the context - which is the proof that a shell script is a complete extension; and once in `examples/Shubbak.Example.FocusTimer`, C# on the `Shubbak.Ipc` package over one connection, which is what the watcher looks like with everything but the pipe taken out. Both were run against a real window manager, through a restart of it. The FAQ answers the question; the end-to-end test gains the first case that raises a signal from one client and hears it on another, pins a context with a lease on a subscribed connection, and sees it let go when that connection closes. - **`Shubbak.Ipc` is a NuGet package.** The pipe's protocol and client - connect, send, subscribe, the payload records, the three hand-read notices - as a package a program of somebody else's references, rather than a project to copy out of this repository. It was already the one assembly with no dependency on anything: not on the rest of Shubbak, not on Win32, plain `net10.0`, every type public, with XML documentation generated and SourceLink pointing back here. What it lacked was the signal: `SignalPayload`, the reader for a `signal` event, lived in the companions' shared library, which is Windows-bound, so a package consumer would have parsed `{"name","arguments"}` by hand. It has moved beside the protocol, gained the writer the daemon now publishes through - one definition, both ends - and its tests moved with it. `dotnet pack` at the solution level used to try to pack all fifteen projects; nothing is packable now but the one that says so. The release attaches the package and its symbols, signs the assembly inside it, hashes it into `SHA256SUMS.txt` beside the installers, and - once the release is published - pushes the attached file to nuget.org by Trusted Publishing, so there is no API key to keep and nuget.org serves byte for byte what the release attached. The version is the product's; compatibility is the protocol's and stays in the pipe name. `RELEASING.md` has the one-time setup; `tools/build-release.ps1 -Nuget` is the stage, and checks the package it made says the right id and version and depends on nothing. - **One pipe connection can both stream events and carry requests.** It always could, on the wire: the window manager reads the next request from a subscribed connection and writes the reply between the events, each a whole line. It was the client library that refused - its subscription read the stream directly and a request reading beside it would have raced it for lines - and the refusal had been written up, in three places, as the protocol's rule. The pipe's one rough edge for a provider followed from it: a watcher that held its leases on one connection and learned that the file was reloaded on a second, opened for nothing else. The client's subscription now reads through one loop that hands each line to whoever it is for - an event to the queue, a reply to the request waiting for it - and a request on a subscribed connection is answered. The watcher and `shubbak context --hold` are one connection each: the pin lives exactly as long as the subscription, the stream ending is the leases ending with nothing to infer, and the watcher is woken the moment it has reconnected rather than on its next look, so a window manager that restarts is holding the watcher's contexts within the pump's own retry. Paid for only when used: a connection that has not subscribed reads its replies in line as before, with no task and no read pending while nothing is asked. A consumer that falls behind the loop is handed a `wm.resync` in place of its backlog - the window manager's own policy, and the notice a client already reads - so a reply is never stuck behind an event the consumer has not got to, and a request that times out on a subscribed connection costs the caller its answer and the connection nothing. The bar keeps its second connection by choice: it re-reads the whole state on nearly every event, and a connection of its own keeps a burst of events and a reply from waiting on each other. Nothing changed on the wire; the protocol version stays at 2, and an older window manager is served exactly as before. Five new tests hold the loop to its promises while events and replies flow at once, and the design note's "noted, not done" is now done. - **The window manager says which file it is running, and whether the disk agrees.** Every program resolves the same search order, so they agreed about the config file - until the window manager was started with `--config`, after which it read a file the search order never finds, and `shubbak config-path`, the palette's "open config" and the bar's reload each named a different one with confidence; the daemon's own code said as much, in a comment. `query config-path` is now the daemon's answer: `{"path":...,"stale":...}`, where `stale` is the one thing only the daemon can say - the file on disk is not what was last loaded, which is the state after a save with `reload-on-save` off and after a reload the daemon refused. `query config` is the file's text, every section, as it is on disk. `shubbak config-path` asks the daemon first and falls back to the search order when nothing is running. And `config.reloaded`, which went out as `{}`, now carries `{"path":...,"accepted":...}`: it was always raised whether or not the file was accepted - the file moved either way - but nothing said which, so the palette re-read a file the daemon had refused and ran on settings the daemon was not, and the watcher did worse: it ran on its defaults, which for a watcher is letting go of every context it holds, so a stray brace saved mid-call dropped `microphone-in-use` and whatever the config disarms during a call. Both now follow only a reload that landed, as the daemon does; the palette's "reload palette" row remains the way round that. Two comments in the repo that believed the daemon only announced accepted reloads are corrected. `ConfigReloadNotice` is the shared reader and writer, so an older daemon's `{}` reads as it always did; tested at both ends, and the end-to-end test asks a real daemon. - **`shubbak diagnose` works with the window manager dead.** A report is most wanted after a crash, and that was the one case it refused: "a report needs the running window manager to describe its state". True of the live tree and the log ring, and of nothing else. With nothing running it now says so at the top - what it is missing and the command that gives the rest once the window manager is back - and reports from what is on disk: the environment, the five binaries beside it with their dates and sizes, the configuration as resolved and as every one of the four loaders reads it with each diagnostic rendered with its caret and the file itself after, the tails of every program's log and of the run before it, the session as last saved, and every crash report from the last week with the newest one whole. `--config ` names the file to read when nothing is running. Logs a running companion still holds open are read all the same, which the first live report was not doing: `File.ReadAllLines` asks for a share the writer refuses, and two of four logs came back "unreadable". `OfflineDiagnosis` is the piece; tested against a scratch state directory. - **Issue templates, a contributing guide and a security policy.** The README has asked for a `diagnose` report since the first release, and nothing asked for it where an issue is written. Three templates now do - something wrong (with the report), a window that will not tile (with `shubbak inspect`), an idea - and a fourth link sends questions to Discussions. `CONTRIBUTING.md` says what CI holds a change to and gives the one command for each check; `tools/check-test-count.ps1 -Fix` is new, and does to the documented test count what `list-diagnostics.ps1` does to the catalogue. `SECURITY.md` says what the pipe does and does not let a process do, in the terms the ADR's addendum already used, and how to report privately. And the minimum Windows - 10 version 2004, build 19041 - is stated in the README and the getting-started page, with the short list of what is Windows 11 only and quietly left out on 10; it had been implied by the winget manifest and nowhere a person reads. - **`shubbak context --set --hold`: a script is a provider.** A lease dies with the connection that made it, and the command line's closed the moment the reply arrived - so `--lease` was refused with a hint to "hold a pipe connection open from your own process", which meant working out the pipe's name from the SID, hand-writing a line of JSON and keeping a stream open. `--hold` is that process: the command pins the context with a lease and stays, and the pin dies with it - Ctrl+C, `Stop-Process`, or the script that started it ending. `$p = Start-Process shubbak -PassThru -ArgumentList 'context --set in-call --hold'` ... `Stop-Process $p` is the whole of a provider now. Underneath it is the companions' reconnecting subscription, so a window manager that restarts is holding the context again within a second of coming back and a reload - which drops the pins of contexts the reloaded file no longer declares and tells nobody - is followed by the pin asserted again; `exit-all` ends the hold, since a process waiting to pin a context on a window manager that is not coming back is what a lease exists to avoid. Refused outright it exits 1 with the reason; started with no window manager it exits 2 at once rather than waiting for one; `--auto` with `--hold` is refused, since one takes off what the other keeps. Verified live: the pin released the instant the process was killed, and survived a `wm-exit` and restart with the hold saying so at each step. `HoldArguments` and `HoldCommand` are the pieces. - **A program can answer a palette question.** `param "p" run="pwsh -File projects.ps1"` on an action names a program, and each line it prints is a choice - the palette's version of the bar's `kind="command"` source, and how a list the window manager knows nothing about gets onto the palette: projects, notes, bookmarks, whatever a script can enumerate. Run when the question is asked, not when the palette opens, so a palette opened for a window pays nothing; the frame opens at once saying what it is asking (`asking for a p…`, with the command line and a `running` badge) and the rows replace it in place when the program has printed - or are dropped, if Escape or another question got there first. A line is the value; a line with a tab in it is what to show, then what to substitute, so a path is chosen by its name. Blank lines are skipped, a value seen twice is offered once, the first thousand lines are read, and a program that prints nothing usable, cannot be started, exits badly with nothing to show, or has not finished in ten seconds says so in the frame, with what it wrote to standard error; one that outstays its welcome is stopped with everything it started. A later `param` may be a program too, and a program's answer may lead to another question. `run=` with nothing to run is an error (`DAL0019`). `MacroParamSource.Script`, `ScriptPrompt`, `ScriptList` and `PaletteEntries.ScriptChoices` are the pieces; `PaletteEntry.Runs` is how a row carries the question, alongside `Composes` and the rest. Verified live: three lines from a PowerShell script became three rows, the tab-separated one showing its name with the path in its command. - **A palette action that runs `shell-exec` says it cannot, rather than doing nothing.** The window manager refuses `shell-exec` over the pipe unless `general { allow-shell-exec-over-ipc #true }`, and every palette action travels over the pipe - so such a row closed the palette and did nothing, with the refusal in a log nobody was reading. The loader now reads the same file's `general` section, and a row that would be refused is listed as unable to run with the setting named; `check-config` says the same (`DAL0020`). Setting the flag and saving is a reload, and the row is back. - **A program can put a value on the bar by raising a signal.** `source "battery" kind="signal"` in the `bar` section makes `{{ battery }}` read whatever `shubbak signal battery 41` last said - the signal's arguments joined by a space, or the empty value when there are none, which hides the widget. The third way onto the bar beside a clock and a program the bar runs itself, for a program that already knows the value and would otherwise have to be started a second time to say it. A signal source has no timer, no thread and no process; and a bar with no signal source never subscribes to the topic, so it costs the window manager nothing per signal and leaves it able to say when a signal was raised with nobody listening - `shubbak diagnose` now lists who is subscribed to what, so that can be seen. A reload that adds the first signal source or removes the last remakes each bar's subscription without the connection pill noticing. `signal=` on the source names a signal other than the source's own name. Because a signal is fire-and-forget and the window manager keeps none of it, the bar raises `signal "announce"` when it connects with a signal source in its file, and after a reload, and a publisher that hears it says its values again; the name is the protocol's (`IpcProtocol.AnnounceSignal`) so a script of your own can honour it too. `SignalSource` and `SourceHub.Signal` are the pieces underneath. - **The watcher publishes three of its readings as values, not facts.** A context is a boolean by design, and the battery's percentage is not one; the watcher read it for `battery-low` and threw the number away. `power { battery-percent "battery" }`, `speaker { device-name "speaker" }` and `microphone { device-name "microphone" }` each publish a signal the bar shows - `41`, `Speakers (Realtek(R) Audio)` - said when it changes and not otherwise, said again when a bar asks with `announce`, and cleared once under its old name when the file drops or renames it. Each is off until named; `#true` names it after its subject. The name is a signal's, not a context's, so it is checked against nothing; an empty one is pointed out (`AYN0012`). `ValuePublisher` in `Ayn.Core` is the decision, pure and tested beside `Provider`; the loop flushes both from one reading. Verified live: the speaker's name was on the bar within a moment of the watcher starting, survived the bar being restarted and reloaded, and cost the idle watcher no CPU at all over thirty seconds. - **The bar scales with the display.** Every size in the `bar` section - `height`, `font-size`, `padding`, `margin`, `radius`, `size`, `gap`, `min-width` - is now in device-independent pixels and scaled to each display's DPI just before layout, so a `height 34` is the same bar on a 4K display at 150 percent and a 1080p one at 100, and a change of scaling in Settings is followed live through `WM_DPICHANGED` without a restart. The scaling is on by default, which means a config tuned in raw pixels on a high-DPI display will find its bar half again as large the first time it loads; `dpi-scaling #false` in `bar` reads the numbers as pixels, as every version before this one did. `VisualScaling` in `Shubbak.Ui` is the arithmetic, applied in place to the tree; a border that exists stays at least a pixel, a padding rounds to the nearest one. `TAJ0026` says when the setting is not a boolean. - **The pointer can do more than click.** `on-right-click`, `on-middle-click`, `on-scroll-up` and `on-scroll-down` join `on-click` on `text` and `icon` widgets, each a command on the same path as a keybinding. A widget with any of the five is a control - hand cursor, hover style - so a pill that only scrolls still looks like one. The `workspaces` widget scrolls on its own: the wheel over it names the previous or next workspace, wrapping at the ends and counting hidden empty ones, quoted the way its clicks are; `scroll=#false` turns it off. `TAJ0023` names the gesture a bad `keyboard` command was written on. `PointerActions` is the record the five settings land in. - **A `command` source can be a script that prints and exits.** With an `interval`, the program is run on that schedule and its last non-empty line is the value - the shape a shell one-liner or an i3blocks script already has - and its exit is neither a failure nor a restart. Without one it is a resident program as before, and a resident program that keeps exiting without printing is now restarted with a wait that doubles to a minute, logged once per step rather than seventeen thousand times a day for one mistyped path. A poll waits out a stand-down and runs at once when the bar comes back. - **The sources are made once and shared by every bar.** Each bar built its own set from the same declarations, so a desk with three displays ran three clocks, three keyboard pollers and three copies of every script. `SourceHub` owns the set and fans each value out to whichever bar models are attached; a bar made later - a monitor plugged in - is handed every value at once, a reload disposes the old set and inherits a stand-down in force, and a second source with the same name is disposed rather than left half-made. - **A clock speaks the language you name.** `culture="de-DE"` on a `kind="time"` source decides what `dddd` and `MMMM` come out as; without one they are English, as they always were. An unknown culture is pointed out at load (`TAJ0032`) where the machine has the data to judge, and by shape where it does not. - **`{{ connection }}` and `{{ workspace }}`.** The first reads `no window manager` while a window manager the bar had reached is gone, and nothing before the first connection, so a bar that wins the race at logon does not open by announcing the window manager missing. The second is the active workspace's name on the bar's own display, for a template that wants it as text rather than as pills. - **`min-width` and `max-width` on any widget.** A floor stops a clock with seconds nudging its neighbours twice a minute; a ceiling cuts with the same ellipsis a shrinking zone uses. Both scale with the display. - **The bar reloads without a window manager.** It watches its own file, as the window manager does, so a bar being tuned on its own follows every save. A save the window manager also announces is one reload, not two: both routes compare the file's stamp with the one that was read. `ConfigWatcher` and `ConfigStamp` moved to `Shubbak.Config` to be shared. - **The bar's silent misconfigurations now speak.** `extends` naming a profile that does not exist or is declared further down (`TAJ0029`, with the nearest name or the ordering rule as the hint), an `edge` or a `justify` that is not one of the words (`TAJ0030`), a colour that does not parse wherever a colour may be written (`TAJ0031`), a source whose `kind` the bar does not know (`TAJ0027`, with a guess) and a `command` source with nothing to run (`TAJ0028`). Every one of these used to produce a bar that was wrong in a way nothing connected to the line, and a clean `check-config`. - **The palette takes the best alignment, not the first.** The matcher took each letter at its earliest occurrence, so `st` against *Visual Studio* landed on the *s* of Visual and the *t* of Studio - scored as a scatter and highlighted as one - when the *St* that starts a word was there to be had. Every placement is now weighed and the best kept, a dynamic programme over query and candidate instead of a walk; the walk remains for titles past 512 characters. An abbreviation is no longer charged for the words it skips, and the camel-case bonus equals the word-start one, since *VisualStudioCode*'s boundaries are words. Every ranking the old tests pinned still holds. - **The palette folds what it compares.** Accents no longer stand in the way - `cafe` finds *Café*, either way round - and the Arabic spellings of one sound are one: the alifs with and without hamza, `ة` and `ه`, `ى` and `ي`, the Persian kaf and yeh and the Arabic ones. Vowel marks are stepped over on both sides, so `محمد` finds *مُحَمَّد* and the letters across a mark still count as adjacent. The Latin table is built at startup from the runtime's own decomposition rather than typed in. - **A space in the palette's query is several words that must all match**, in any order: `code proj` finds *My Project - Visual Studio Code*. The trailing space before a next word is not a word yet and no longer empties the list. - **The palette's search box has paste, select-all and whole-character editing.** Ctrl+V and Shift+Insert paste - a copied line with its newline becomes one line, and a pasted `>` into an empty palette changes mode as typing it would. Ctrl+A selects what was typed, drawn as a pill, so the next key replaces it; the prefix is not part of it. Left, Right, Backspace and Delete step by text element, so an emoji or a letter with its combining mark is one step and one Backspace rather than two, and the caret can no longer land inside a surrogate pair - where the renderer had half a character on each side to measure. An emoji typed from the Win+. panel, which arrives as two messages, is held until both halves are in. `Clipboard.GetText` joins `SetText` in `Shubbak.Native`. - **The command list learns.** What is run from it is remembered - a count per verb or action that halves every fortnight, `Frecency` in `Dalil.Core` - and before a letter is typed the list is in that order; once one is, the match decides and the history settles ties. Anything used sits above everything unused whatever its kind; the unused keep their alphabet; a `not now` row stays where it is. The record is `%LOCALAPPDATA%\Shubbak\dalil-frecency.tsv`, one line per command, written whole and renamed into place, capped at two hundred entries. - **The palette's empty list speaks the language of its mode.** "Nothing to show - the window manager may still be starting up" served every mode and was misleading in most: an empty scratchpad now says nothing is stashed and how to stash something, an empty inspect list says every window is managed, an empty workspace list says where they are declared, and only the lists the window manager fills say they are waiting. `EmptyStateText` is the pure function behind it. - **A palette prefix that is a letter or a digit is pointed out** (`DAL0018`). `layouts "l"` - the example the docs gave - meant typing `l` into an empty palette changed mode instead of searching for Lightroom, for as long as the setting stood. The example is now `^`. - **The watcher grew from three facts to ten.** `screen-captured` (a program is sharing or recording the screen, from the `graphicsCaptureProgrammatic` consent record the shell's own indicator reads), `speaker-muted`, `on-battery`, `battery-low` (at `battery-low-at`, twenty percent unless said), `lid-closed`, `user-away` (Windows's own judgement that nobody is at the keyboard) and `dark-theme` join `camera-in-use`, `microphone-in-use` and `microphone-muted`. The new ones are off until the file names them, so a file that never mentioned the watcher does not wake up holding contexts it never declared. Each source is opened only when a fact needs it: `PowerWatch` registers for power-setting notifications with no window and no timer, `ThemeWatch` is a registry notification on `Personalize`, the screen's key is opened only when the screen is watched, and the audio endpoint follows the default speaker only when asked. None of them settle; the mains lead is in or it is not. - **A program can be told not to count, and a program can be given a context of its own.** `camera { ignore "obs64.exe" "Lens*" }` says the recording tool that keeps the camera open all day is not a meeting; `camera { by "ms-teams.exe" "in-a-call" }` holds `in-a-call` while that one program has the camera, so a Teams call and an OBS stream can be told apart without a context knowing the difference between programs. Names are matched as `ayn --report` prints them, with `*` and `?` as wildcards and no regard for case; `microphone` and `screen` take the same two settings. A rule is a slot of its own in the provider - refused, released and reloaded independently of the fact about any program - and one added by a reload is judged against the last reading at once. - **`renew` re-asserts every held context on a schedule with a time to live.** `ayn { renew 60 }` sends each hold again every minute as `--lease --ttl 120s`, so a watcher that is alive but stuck - connection open, loop wedged - loses its pins a little after it stops renewing them. Off unless said, deliberately: the lease already dies with the connection, and a window manager stalled past the time to live would drop a context and take it back, running whatever the file hangs on that. - **`signal "ayn" "speaker" "mute" | "unmute" | "toggle-mute"`** flips the default speaker's mute as the microphone's is flipped, and `ayn --report` prints the speaker, the power and the theme beside the devices. - **The watcher's new mistakes are pointed out.** Two facts naming one context (`AYN0006` - each hands it back when it goes false and takes the other's pin with it), `camera "meeting"` read as `camera { in-use "meeting" }` (`AYN0007`), a `by` rule missing its program or its context (`AYN0008`), a `renew` that is not a number of seconds or is under five (`AYN0009`), and a `battery-low-at` outside 1 to 100 (`AYN0010`). A `by` rule's context is checked against the `contexts` section like every other name. - **Four verbs and three flags the keyboard was missing.** `swap --direction left` exchanges the focused window with its neighbour that way, wherever in the tree the neighbour is, and changes nothing else about the shape - where `move` into a neighbouring container joins it. `gaps --inner +4`, `gaps --outer -4` and `gaps --inner 0 --outer 0` change the gaps for the session, by a signed amount or to a value, for the key that closes them up for a screen-share; a reload puts the file's back, and a `gaps.changed` event says so. `toggle-maximized` fills the work area with the focused window, frame and all, and puts it back - `WindowState.Maximised` had been in the enum since the beginning with nothing that set it. `focus --monitor right` and `focus --monitor laptop` go to another display outright, where `focus --direction` crosses only when nothing within the workspace is that way; `move --monitor right` puts the focused window on that display's active workspace, following with `--focus`. `layout --masters +1`, `-1` or `2` sets how many windows a master-stack layout keeps in its master area, a count the layouts had carried since they were written with no way to change it. `SHB0323` to `SHB0325` are the new refusals. Every one is in the catalogue, so the palette completes it. - **Ayn: `device` rules for the speaker and the microphone.** `speaker { device "*barracuda*" "on-headset" }` holds a context while the default speaker is a device whose name matches - the name Windows shows, with the same wildcards `by` takes - and lets go the moment Windows moves the default; `microphone` takes it too. The name is read from the device's property store once per resolve, so following it costs nothing per wake, and `ayn --report` prints both names so the pattern can be copied. A rule missing its name or context is pointed out (`AYN0011`); one sharing a fact's context gets the same warning two facts on one context get. Verified live: the headset's name held `on-headset` on the window manager within a moment of the reload. - **`no-focus` in a rule.** The window it matched is managed and placed but focus stays where it was - for the chat that pops when a message lands and the updater that opens a window nobody asked for. Managing gave every new window focus, since that is what a window somebody opened wants, and the only remedy for the other kind was a rule that sent it to another workspace. Holds against the program's own activation too: a freshly launched program takes the foreground itself a beat after its window is shown - Notepad, 27 ms after being managed - and the first cut of the rule followed that as if the user had clicked, so it kept focus about half the time when tried live. For a second and a half after the arrival that activation is put back instead; a click on the window is followed whenever it comes. Five launches in a row kept the foreground where it was. - **`release=#true` on a binding** runs it when the key comes up rather than when it goes down: a push-to-talk, or a `signal` that should fire as a held key is let go. The press is still swallowed, so the key reaches no application either way; the hook delivers a release only for a binding that asked for one, so every other binding costs what it did. A release binding never repeats. - **`shubbak query tree`** prints the tree as text - monitors, workspaces, containers and windows with their layouts, ratios and rectangles - the same rendering `shubbak diagnose` puts in its report, for reading the tree as it is rather than reconstructing it from `query windows`. - **A catalogue of every diagnostic.** `docs/diagnostics.md` lists all 156 codes the four loaders can print - SHB, TAJ, DAL and AYN - with severity and message, generated from the source by `tools/list-diagnostics.ps1` so it cannot drift. The docs also caught up with the code: thirty event topics, not twenty-eight; the session keeps workspaces, tags, stickiness and state and not layouts, ratios or floating rectangles; the pipe's methods beyond `command` and `query` - `inspect`, `window-icon`, `add-rule`, `remove-rule`, `diagnose`, `log-level`, `ping`, `subscribe` - are written down; and the watcher's microphone is the default communications one, falling back to the default, as the code has always had it. - **`shubbak setup`: from a fresh install to a running desktop in one command.** It writes the starter config if there is none, registers the window manager to start at logon, starts it, and prints the keys - each step skipped when already done, so running it twice is harmless and running it after a half-finished first attempt finishes the job. `--no-autostart` and `--no-start` leave a step out; `--config ` uses a file of your own and records it in the Run key. The winget and Scoop installation notes, the README and the getting-started page now say this one command where they used to list three across two executables. - **The window manager writes its own config on a first run.** Started with no config anywhere in the search order, `shubbak-wm` used to run on defaults - every window tiled, no key bound, no bar, no palette - and the only thing that said so was a warning in a log nobody had opened. That was the experience of anyone who clicked the Start Menu shortcut before reading the docs. It now writes the starter to the first user location (the same file `config init` writes), loads it, and the tray icon shows a notification naming the file and the one key that leads to everything else. An existing file is never overwritten, and an explicit `--config` path is still honoured even when it does not exist. `TrayIcon.ShowNotification` is the new piece underneath. - **`shubbak-wm --autostart`** registers the binary to start at logon, with the same arguments less the terminal ones, before starting. The Run-key code moved from the CLI into `Shubbak.Native.RunKey` so the two cannot write different things; `shubbak autostart` is unchanged on the outside. - **The MSI's final dialog has a checkbox** - *Start Shubbak now, and at every logon* - ticked by default, which runs `shubbak-wm --autostart` as the logged-on user from the unelevated client half of the install. Somebody who double-clicks the MSI now gets a working desktop from the installer alone. Silent installs never show the dialog and are unaffected. - **`shubbak doctor`: the install as a checklist.** Where `diagnose` writes a report for somebody else to read, this answers a dozen yes-or-no questions for the person at the keyboard, each with the command that fixes a no: are the four executables where they should be; is the install directory on this terminal's PATH (the thing that is stale right after an install); which config is in effect and does it parse in all four loaders; does the Run key exist and point at this copy; is each of the four programs running, and if the config starts one that is not, where its log is; is `alt+space` bound while PowerToys Run is running; is another tiling window manager up; does the config name a font or a backdrop this Windows does not have; is a portable install expecting to move elevated windows; is there a crash report from the last week. Exits non-zero when something is wrong, so a script can ask. It also resolves each companion's `startup-command` the way the window manager does - a bare name beside `shubbak-wm.exe`, then PATH - and warns about an absolute path to `taj`, `dalil` or `ayn`, which works on the machine it was written on and on no other; a bare name is the portable spelling. - **The palette opens on its key list the first time.** The very first time Dalil is opened on a machine with no mode asked for, it shows `?` - every key and every prefix - instead of the window list. A newcomer has just pressed the one chord the setup told them about, and the most useful thing to show them is every other chord; the windows are a Tab away and are what they get from then on. One empty marker file, `%LOCALAPPDATA%\Shubbak\dalil.opened`, is the record; delete it to see the keys again. - **The focused window's icon, on the bar and over the pipe.** A new pipe method, `window-icon`, answers with a window's icon as pixels: the daemon asks the window the way the taskbar does (`WM_GETICON`), then its class, then takes the executable's own, and hands back premultiplied BGRA in JSON with `source` saying which answered. A capability of the window manager rather than of any client, for the reason the title already travels this way - one process asks windows things, and the ask is bounded to a tenth of a second, gives up at once on a hung window, runs off the daemon's loop and is remembered for half a minute per window. Taj's new `icon` widget draws it: `icon size=20` beside the title, hidden when nothing is focused so the title gains no gap, with `background`/`radius` for a pill and `on-click` like any control; the bar caches per window for a minute and forgets a window when it is unmanaged, so a day of switching between the same windows costs one read each. Underneath: `VisualKind.Image`, `ImageBitmap`, and an `IImageRenderer` capability the composited renderer implements with area-averaged scaling, so a 32-pixel icon drawn at 20 keeps its edges. - **The palette takes its icons from the same place.** Dalil used to send `WM_GETICON` to every window it listed, with a timeout to bound what a hung one could do and no answer at all for a window that had never set an icon. It now asks the window manager's `window-icon` over the pipe, on the background thread that already reads the list, once per window it has not seen - so a window that set no icon shows the one its taskbar button does, the palette sends no message to any window, and both processes share one implementation and, in effect, one cache. The opaque renderer draws the pixels through `AlphaBlend` after resampling them to the row's icon square with the same area-averaging the bar uses, from a scratch surface kept between rows. The handle-based `IIconRenderer` and its `DrawIconEx` path are gone; nothing drew through them any more. - **`accent` is a colour.** Anywhere a colour is written - the bar, the palette's theme, a focus border - `accent` is the colour Windows is set to, read from the compositor, and any colour may be followed by an opacity: `accent 40%`, `#8dbcff 40%`. The bar re-reads its config when Windows announces a change of accent, so the bar follows the title bars; the window manager resolves it each time it paints a border. Every executable adopts the machine's accent at startup and the parser falls back to the stock blue without one, so a file is valid in every process or none. - **The bar can be translucent, and can float.** Taj's `background` now honours its alpha channel: `#1e1e2eb3` is a genuinely see-through bar rather than a darker opaque one, and every pill, hover and dimmed colour on it composites properly, with anti-aliased corners where GDI's never were. `backdrop "acrylic"` (or `mica`, `tabbed`) asks Windows 11 for a material behind the translucent pixels, dark or light to match the bar's own colour; `margin 8` floats the bar off the screen edges with the desktop around it, reserving the same room below as above; `radius 8` rounds its corners; `border "#ffffff14"` draws a one-pixel hairline - an outline on a floating bar, and along the edge that faces the windows on a docked one. All five inherit through `extends`. Underneath: a second renderer, `CompositedGdiRenderer`, which owns a 32-bit premultiplied back buffer and reads GDI's text back as a coverage mask, so glyphs blend instead of punching holes; the window asks the compositor to respect its alpha through the blur-behind call with an empty region, the way every transparent toolkit on Windows does. Text is smoothed in grayscale rather than ClearType, which only ever looked right on an opaque background. The palette keeps the renderer it had. An untouched config draws exactly the bar it drew before. Unknown `backdrop` names and negative measures are pointed out (`TAJ0024`, `TAJ0025`). - **Click the language indicator to switch it.** `on-click="keyboard next"` on the `{{ keyboard }}` widget switches the window in front to its next installed layout; `keyboard previous` goes the other way and `keyboard he` picks a language by its two-letter code. The one click command the bar performs itself rather than sending to the window manager: it already reads the layout of the window in front, and changing it is a message posted to that same window, so the round trip would have bought nothing. The indicator follows on its next poll. A `keyboard` command the bar cannot perform is pointed out at load (`TAJ0023`); the window manager's verbs are, as before, left for the window manager to judge. - **Borders on chosen sides.** `VisualStyle.BorderSides` lets a node border one edge rather than all four - the docked bar's hairline, and the underline an active item will want - drawn as filled strips so no renderer needs a notion of partial outlines. - **The palette writes the rule and adds it.** The common reason to want a rule is the common thing `toggle-managed` cannot do: make a window stay ignored, or stay managed, across a reload and a restart - the toggle is remembered in memory and forgotten at both. "Write a rule for it…" now opens the rules that could be written for the window, each complete and named for what it does - `ignore ms-teams`, `manage WhatsApp`, `msedge on 2` - best first: a managed window is offered **Ignore it**, a window the filter turned down is offered **Manage it** (and floating, and on a workspace), a window a rule already decides is offered **Stop ignoring it** / **Stop forcing it**, which removes that rule. Enter reads the rule; Ctrl+Enter's **Add it to the config and reload** appends it to `shubbak.kdl` and reloads, and the answer says what happened to the window - released, adopted, or nothing - with **Remove it again** right under it, because it did not ask first. **Copy the rule** and **Open the config** remain. Every rule row in an inspect report can be removed the same way, and removal offers to put the rule back. "Match it, decide later" is the old behaviour, always last: the matchers written, the `do` block left to you, and no offer to add what the loader would drop. - **The window manager edits the file, and refuses before it breaks it.** Two new pipe methods, `add-rule` and `remove-rule`. The daemon knows which file is in effect, has the loader, and has to reload anyway. Adding appends a `rules { }` block of its own under a `// Added by Shubbak` comment; removing cuts the rule's lines and takes the block and the comment with it when the rule was all it held, so adding and removing leave the file as it was found, line endings, byte-order mark and all. Every edit is validated as the window manager would load it and refused - file untouched - when the file already has errors, when the rule would not load or would be dropped, when the block holds anything that is not a rule, and when a rule runs `shell-exec` without `allow-shell-exec-over-ipc`. Removal is by name **and** line, as a report gave them, and refused when the file no longer agrees. `general { allow-config-edits-over-ipc #false }` turns it all off; on by default, because every process that can reach the pipe can already reach the file. - **`shubbak rule list`, `rule add` and `rule remove`.** The same from a terminal: `rule add --ignore` clicks a window as `inspect` does, `--manage --float`, `--workspace 2`, `--print` to see the rule instead, `--file rules.kdl` (or `-`) to add rules written by hand; `rule remove "ignore ms-teams"` finds the line from the list and prints the rule so it can be put back. `query rules` lists the rules in force with their lines, verbs and triggers. - **Saving the config reloads it.** Reload was entirely command-driven, so editing the file meant remembering to press the reload key, and the first sign of having forgotten was a rule that appeared not to work. The window manager watches the file's folder - a directory notification, nothing polled, so a file nobody is editing costs nothing - and reloads a moment after any editor saves it, in-place or write-rename alike, one reload per save. The reload is the ordinary one, gate and all: a save with errors is reported and the running configuration is kept. Its own writes are recognised and not loaded twice. `general { reload-on-save #false }` turns it off. Measured: 3 ms of CPU per reload on a 48 KB file with every open window re-examined, no growth in memory across sixty of them. - **Every `rules { }` block counts.** The loader read the first and dropped every other one in silence, which made "paste this at the end of your file" wrong advice for every file the starter config produces. Blocks are read in file order, unnamed rules are numbered across them, and a context's blocks likewise. - **`SHB0452`: `ignore` or `manage` under `on="title-change"` or `on="focus"`.** Both decide whether a window is taken on at all, so both act only on the manage trigger; written elsewhere they parsed, loaded and were stripped at the moment the rule fired, and the report showed the rule matching. A warning; the rule is kept for whatever else it does. - **Show every window.** The inspect list left out the windows the filter turned down for their shape - tool windows, popups, windows that keep out of Alt+Tab, windows with no title yet - which are precisely the windows a `manage` rule exists for, so the rule could only be written with a handle from somewhere else. A row at the top of `!` now widens the list to them and back; the widening lasts until the palette closes. `query every-window` is the same over the pipe. On one desktop: 10 rows became 28, in the same 16 ms. - **Reports say whether a rule could change the verdict.** `inspect`, the palette and `query all-windows` now carry whether the filter's reason can be overruled by `manage` - a tool window can, a cloaked window cannot - and each rule's verbs and trigger, so a matched rule's consequence is read off the line rather than looked up. All appended and optional; an older client sees what it always did. - **`open config`** in the command list, beside `config path`. The path is still what a terminal, a bug report or an editor that is already open wants; this is for when the next thing to happen is typing into the file. - **`exit-all`.** Shuts down all of Shubbak - the window manager, the bar, the palette and the watcher - from a keybinding, the palette or the CLI. `wm-exit` is unchanged and still stops the window manager alone: the palette and the watcher stay and reconnect when it is back, which is what `--replace` and an upgrade rely on, and the bar waits a while for the same reason. That is the right behaviour for a restart and was the only behaviour there was, so the tray's **Exit Shubbak** left a palette and a watcher running behind it for a window manager that was not coming back. The tray item now runs `exit-all`, and so does the starter config's `alt+shift+e`. The window manager says which was asked in the `wm.shutdown` notice - `{"everything":true}` rather than `{}` - and the palette and the watcher act on it; a client that reads `{}` as it always did is unaffected. `shubbak stop` remains the way from outside, and the only one that works when the window manager is already gone. ### Changed - **The starter config is a desktop rather than a skeleton.** What `config init`, `setup` and a first run write is now a translation of the author's own daily config with the machine-specific parts taken out: borders in the focus colour with a separate colour for floating windows; animation on, following the display's refresh rate; ten workspaces; rules that leave the Run box, picture-in-picture video, PowerPoint slideshows and the PowerToys accent popup alone; `logging { }` so `diagnose` has a file to collect; keys for the recent window and workspace, equalise, every layout, fullscreen in both sizes, minimise, managed/floating, one scratchpad, moving a workspace between monitors, and the three degrees of "leave me alone" - pause mode, stop arranging, suspend - each with its pill on the bar; a `resize` mode and a pass-through `pause` mode; `meeting`, `meeting-live` and `meeting-muted` composed from the watcher's facts so the bar shows exactly one microphone; a dozen palette actions, most of them questions (*Go to...*, *Send it to...*, *Layout...*, *Arrange...*, *Enter mode...*), plus *Gaming*, *Reset this workspace* and *Reset everything*; and a bar with a translucent acrylic background, workspace colours for empty / has windows / on another monitor / has the keyboard, the focused window's icon and title, every pill, the camera and microphone glyphs, the keyboard language, the layout glyph and the clock in the Windows accent colour, with a `presentation` profile left defined as an example. Roughly four hundred lines to the old hundred and eighty, and still one sitting to read. The setting `focus-follows-cursor`, which parsed and did nothing, is gone from it. - **The palette is on `alt+shift+space`**, not `alt+space`. Windows uses `alt+space` for the window menu and PowerToys Run takes it by default, and the collision was the first thing every new user hit. `alt+ctrl+space` opens the palette on its commands list; `alt+w` cycles the layout, which `alt+shift+space` used to do. - **The starter is a file, not a string constant.** It lives at `src/Shubbak.Config/StarterConfig.kdl` and is embedded, so it can be read, diffed and validated as KDL, and so that the window manager can write it as well as the CLI. `StarterConfig.Text` and `StarterConfig.WriteIfMissing` are the API; the tests that hold it to account now also check that the palette is on `alt+shift+space`, that every escape hatch is bound, that every action is well-formed and that every action a key runs by name exists. - **`shubbak --help`'s GETTING STARTED** leads with `setup` and `doctor`, with the three individual pieces listed under them. `shubbak-wm --help` documents `--autostart` and what happens on a first run. The "no config file found" message suggests `shubbak setup` first and `config init` second. The docs use the winget moniker - `winget install shubbak` - which the manifest has declared all along. - **`focus-follows-cursor`, `cursor-jump` and `logging { console }` are gone.** All three parsed, were stored, and were read by nothing, for their whole existence; the example file shipped the first two and the documentation said they did what they said. Shubbak follows the keyboard and never moves the pointer, and the log goes to the console whenever there is one. A file that still has them is told so by name (`SHB0455`, a warning) with why nothing replaced them, rather than being offered "did you mean `focus-follows-cursor`" as an unknown setting. - **The configuration page says what reloading is now.** It said reloading was explicit and that nothing watched the file, which stopped being true when `reload-on-save` became the default. ### Fixed - **The end-to-end suite's refusal to run beside a window manager now says which one it found.** The x64 job once refused with "shubbak-wm is running" in a run where the only window managers to have existed were the ones the three tests before it had started, stopped with `shubbak stop`, and seen exit - and nothing in the message said whether the process it found was one of those or something else, so the failure could not be explained and was not. The guard now names every process it found: the id, how long ago it started, where it was started from, and whether this test host started it; and the one case that is certainly harmless - a daemon of the suite's own that has already reported its exit and is somehow still listed - is waited for, for up to five seconds, rather than refused. A stranger, or a daemon of ours that has not exited, is refused as before. The cause of the one refusal seen is not established: a terminated process is not listed by name while a handle to it stays open, nor between its exit code being set and its teardown finishing, on any machine this was tried on, under load or not - so whatever it was, the next time it happens the message will say. - **Two palette tests raced a timer against a two-second ping, and on a loaded runner the ping won.** `CancellingTheWaitStopsTheProgram` and `AProgramThatOutstaysItsWelcomeIsStoppedAndSaidSo` ran `ping -n 3` and expected a 200- or 300-millisecond cancellation to stop it. On .NET 10 a redirected stream is a synchronous `FileStream`, so each read holds a thread-pool worker in `ReadFile` - two per run, one per stream - and a `CancellationTokenSource` with a delay fires as a thread-pool work item too. With other test classes on the pool's remaining workers, the timer had nobody to run it; a worker whose read returned took its own next read before the queued timer; and when the ping finished with a clean exit the run was a success, since `WaitForExitAsync` consults the token only for a process still running. The ARM64 job failed exactly so - `Succeeded` true after two seconds - while the x64 job on the same commit passed, and the sequence was reproduced on demand by pinning the pool to its processor count and blocking all but two workers: 2060 ms, ten lines, cancelled a moment too late. The raced programs now run for half a minute, so a timer that is seconds late still cancels something that is running, and one that is thirty seconds late fails loudly rather than by chance; under the same induced starvation the test passes five times in five at three seconds, and in half a second with the pool free. The palette's own cancellation was never at fault - with a worker to run it, it interrupts even a program that prints nothing - so `ScriptList` is unchanged. - **The native and end-to-end test suites no longer fail each other.** Both create real windows on the desktop: the native tests spawn a `winver` and assert what the shell does with it, and the end-to-end test starts a window manager of its own that manages every `winver` it can see. `dotnet test` runs the two hosts at the same time, so the native tests' guard against a running window manager - right, when it is the user's - found the end-to-end daemon instead, one full run in three on the machine that measured it and every run on a faster one, and refused nine to twenty-five tests with a message blaming a window manager the user had not started. Each suite now takes a named mutex for the desktop before it touches it - the native host for the length of its run, the end-to-end test around each daemon - and whichever gets there second waits. The guard against the user's window manager is unchanged; the only thing that moved is the overlap, which was never meant to exist. A host that dies holding the mutex abandons it, and the next waiter is told and goes on, which is why it is a mutex and not a semaphore. - **The end-to-end smoke test read a tiled window's rectangle a tick early.** It waited for the rule's verdict - "tiling" - and read the rectangle, and the layout pass that places the window runs a tick after the verdict, so on a fast machine it read a window of width zero, one run in three. It waits to be placed. - **A rule's `tile`, `float` or `move` was refused when an unmanaged window held the foreground.** A rule acts on the window it matched, and the daemon puts the tree's focus there before running the rule's commands - but the commands then went through the same gate a keybinding's do, which asks the desktop which window is in front and refuses when that window is not one Shubbak manages. A rule's window is not the one in front: it has just appeared, often behind whatever was, and when what was in front was the desktop, Task Manager, or an application a rule ignores, the rule's command was refused with a complaint about that other window - `tile: "Netflix - Mozilla Firefox" is not managed` - and the matched window was left as the filter had it. Masked whenever the user's focus was on a managed window, which is most of the time, and so never reported. A rule's commands now declare their target settled and skip the foreground check; keybindings and the pipe keep it. Found by the new end-to-end test on its first run, which is what it is for. - **A workspace named with a `|` broke the workspace strip.** The bar carries its workspaces to the widget as one string with `|` between fields and a tab between records, and a name containing either split into half-records too short to read: the workspace vanished from the strip and every one after it shifted a field. Names and labels are now escaped on the way in and unescaped on the way out; plain names are on the wire exactly as before. - **The bar drew over another docked bar's strip.** It set its strip with `ABM_SETPOS` alone, which the shell grants as asked, so a second appbar on the same edge - a docked toolbar, another bar - was overlapped rather than made room for. It now asks with `ABM_QUERYPOS` first and follows the rectangle the shell hands back, moving the window to where the strip actually is. - **A value no widget showed still repainted the bar.** Every source publish marked the model dirty whether or not the profile in force read the value, so a clock declared for a variant that was not showing woke the bar twice a second for nothing. The model now knows which keys its widgets read and wakes only for those; the "re-renders only when a source it uses changes" the docs promised is now what happens. - **The palette's contexts pill could not be read.** It is filled with the accent - whatever colour Windows is set to - and its text was the chip's: the accent lightened a quarter of the way to white, which on a light-blue accent is the accent on itself. The names of the contexts in force were invisible. Text on the accent is now the background when the accent is light and white when it is dark; `Colour.IsDark` is the one luminance test, shared with the bar's backdrop. - **Alt+F4 in the palette closed the palette process.** Left to `DefWindowProc` it became `WM_CLOSE`, and the process left, taking the keybinding that opens it with it until the window manager was restarted. It now dismisses the popup - one level at a time, like Escape. - **A reload shrank the palette on a high-DPI display.** `Reconfigure` installed the file's numbers raw, so a palette on a 150 percent display that had its config saved came out two thirds the size until it was closed and opened again - and stayed that way if it was open at the time. The reloaded config is scaled as the original was. - **The palette's help list showed the stock prefixes whatever the config said**, and completing a verb from the command list wrote a literal `>` even when commands had been moved to another character, so the palette went looking for a window called `>focus`. Both now use the table in force. - **A burst of window events started a read per event, all racing down the pipe**, and whichever finished last was installed, whether or not it had started last. One read is in flight at a time; a request during it is a note to go again when it lands, and every read is numbered so a slow one can never land over a newer one. - **Right-to-left titles in the palette were drawn back to front and broken apart.** The highlight laid the matched and unmatched pieces out in logical order from the left, which for Arabic and Hebrew reversed the words and, because an Arabic letter takes its shape from its neighbours, changed the letters at every colour change. A title with right-to-left text in it is now drawn whole and unhighlighted. - **A microphone unplugged left its mute reported for ever.** The audio endpoint answered device-state and device-removed notifications with nothing, so the watcher went on reading the mute off an endpoint that was in a drawer. Both now wake the loop and make it ask for the defaults again; a default that is gone reads as not muted and the context is let go. - **The bar's icon was off-centre and cropped in a short bar.** An icon is its picture in four pixels of padding - the pill it shows on hover - so `icon size=20` is a node of 28, five too tall for a `height 23` bar. The layout clamped the centring offset at zero, which put the whole overflow at the bottom: at 150 percent the 30-pixel picture sat six pixels from the top and one over the bottom edge, so a Windows Terminal icon lost its "PRE" badge and a round Firefox icon touched the edge. An overflowing child is now placed where its alignment says and clipped by the row's edges - centred means the overflow is split, end means the far edge stays put - so the clipped part is the icon's own transparent padding and the picture is whole. Measured on the bar: the picture moved from rows 6-35 to rows 3-33 of 35. - **The watcher takes on a device named by a reload.** The consent store was built once for the devices the file named at startup, and a reload that added `screen { captured "..." }` was told to restart the watcher. The store now opens and arms a new device's keys on the spot and the loop takes up its events, so the reload is the whole change. Verified live: the screen appeared in the watch list four seconds after the save, and left with the revert. - **The pipe server was not listening the moment `Start` returned.** Its listener pipes were created inside the accept loops' tasks, so a client connecting in the next few milliseconds - a script that starts the daemon and talks to it, a test - found no pipe and was told the window manager was not running. The first instance of each listener is now created before `Start` returns. And `IsServerRunning` enumerates the pipe namespace more than once before answering no: a live directory enumeration overlapping another process's pipe churn was seen to skip an entry, and a false "not running" is the worse mistake. - **Closing a window is said in the log.** `close` is the one thing the window manager does to a window that cannot be undone, and it was logged only at debug - so a window that vanished during a session of tests could not be traced to the key that closed it. One line at info per close, naming the window. - **The log kept one earlier session; it keeps three.** A window went missing during a run of tests that restarted the window manager eight times, and by the time the question was asked the start that had the answer was seven rotations gone. Each program now keeps `.1` to `.3` beside its log - a start is the cheapest thing to do to these programs, and the log worth reading is often two starts back. - **A resize pulled the two windows apart while it animated.** Frames are posted to each window's own thread rather than sent - the measured choice that keeps a busy application from stalling the daemon - so the window whose application was re-laying out its contents applied its frames late while its neighbour kept up, and the shared edge opened into a gap or an overlap that closed only when the motion ended; a key auto-repeating every thirty milliseconds against a hundred-and-forty-millisecond curve never let it. A resize now has its own animation profile, `resize`, instant by default: both windows land in one atomic batch and the edge stays one edge. `animation { resize duration=100 }` brings the motion back for anyone who prefers it. - **Resize did nothing in the spiral.** `resize` looked for an ancestor whose primary axis matched, and `fibonacci`, `grid` and `monocle` have none - so in the layout most people start in, the resize keys logged "No container splits along Horizontal" and moved nothing. The spiral honours every ratio it is given; only the question was wrong. Each layout now says what it resizes (`ILayout.Resizes`): the spiral along either axis, the splits along their own, the master layouts along their divider. The grid and monocle genuinely cannot, and the refusal now says so in their own words, as does a resize across a master layout's stack - whose windows share their space equally by design - and a window alone on its workspace. - **Win+Up was undone as drift.** The tree had a Maximised state that nothing set, and the committer cleared the maximise flag on every window it moved, so a native maximise lasted until the next layout pass. The flag is now read on the timer that watches for full-screen (one style read per displayed window), and the two agree in both directions: Win+Up, the title-bar button or an application maximising itself puts a tiled or floating window into Maximised, and Win+Down or the restore button puts it back where it was; `toggle-maximized` is Windows's own maximise underneath. A change the tree made is trusted for a second before the flag is read against it, so the committer's zoom landing a frame late is not mistaken for the user undoing it. `NativeMaximise.Decide` is the rule, as a table. A maximised window is never animated - frames resizing it cancelled the maximise - and a window still carrying the flag is placed rather than animated, since only the placing path clears it. - **Un-maximising a WinUI window did nothing.** Windows 11's Notepad answers true to `SetWindowPlacement(SW_SHOWNORMAL)` and stays maximised, so it was tiled while the compositor still drew it full-size. The flag is now checked after the placement and a plain `SW_RESTORE` sent when it is still set, which such windows honour. - **A window's minimise-end event is only acted on for a minimised window.** It used to restore any away state, which was harmless while Maximised was never entered and would have been a way out of it nobody asked for. - **A floating window left off every screen stayed there.** Its saved rectangle described the display it was on, and when that display was unplugged, rearranged in Settings or its workspace moved to a smaller one, the window was placed there anyway - off screen, with the tree saying it was visible. A floating rectangle wholly outside its workspace's work area is now slid in by the least that puts it on screen, and shrunk if it is larger than the area; one that touches the screen is the user's and is left alone. - **Moving the focused window to another monitor and following it left the focused monitor behind.** `SetFocus` did nothing when the window was already the focused one, so a directional push across a boundary - or `move --workspace --focus` to a workspace on the other display - kept the focused monitor where the window had been, and the next directional command was measured from the wrong screen. The focused monitor now follows the focused window's workspace whether or not the window changed. A test that encoded the old behaviour has been corrected. - **`row` and `rows` name different layouts, and now the docs say so.** The aliases follow the count - one row of windows is `splith`, several rows stacked is `splitv`, and the same for `columns` and `column` - which is consistent and easy to trip on. Re-mapping either would silently change a working config, so the rule is written down instead. - **The watcher spun a core whenever the window manager was down and any fact was true.** After a send failed, Ayn forgot what it held so it could hold it again, and everything still true was then due at once - a pending time of zero. The wait was computed from that: zero milliseconds, at once, in a loop, measured at 99% of a core from `wm-exit` until the window manager came back. The mute is a fact that is true for hours. The retry interval is now the floor while the window manager is unreachable; measured at 0% on the same desk, with the lease re-held within a second of the window manager returning. `Provider.NextWait` is the decision, and is tested for the exact case. - **A context the window manager refused was never held after the file was fixed.** The likeliest refusal is "no context called `camera-in-use`" - the file has not declared it - and the fix is to declare it and save. The watcher booked the refused hold as sent, and on reload re-asserted only facts whose *name* had changed, so the declaration made no difference until the fact toggled or the window manager restarted. A refusal is now unbooked and not asked again until a reload or a reconnect, which are the two things that can change the answer; and a reload re-asserts every held context under an unchanged name too, because the window manager drops the pins of contexts the reloaded file no longer declares and tells nobody. A hand-back waiting out its settle is left to go out on time. Provider gained `Sent(action, outcome)` so the bookkeeping lives where it is tested. - **A second copy of the bar, the palette or the watcher truncated the first copy's log.** Each opened its log file before claiming the single-instance lock; opening truncates, and the copy already running was writing to it. Nothing strange had to happen: a restarted window manager runs its startup commands, so every `--replace` and every upgrade wiped `taj.log`, `dalil.log` and `ayn.log` mid-session. The lock is claimed first now. The duplicate also no longer conjures a console to say "already running" into - a black window flashing on every restart for nobody - and says it only to a terminal it was actually typed into (`ConsoleHost.TryAttach`). - **A display enumeration that returned no monitors orphaned every window.** An empty answer - which the enumeration gives while a remote session detaches its display, while a driver resets, and when one display could not be described - removed the last monitor, and the tree removes a last monitor by detaching it with every workspace and window still inside, since there is nowhere to migrate them. The display that came back got a fresh node and fresh workspaces; the old windows stayed managed and stayed cloaked, unreachable until a restart. An empty enumeration is now ignored, said once per episode, and one display that cannot be described no longer empties the list of the rest. - **A floating window minimised and brought back is still floating.** Every way back from minimised or fullscreen wrote `Tiling`, so a floating dialog was tiled into the layout by the act of minimising it, and a floating window made fullscreen and un-made came back tiled. The window remembers the state it was put away from (`WindowNode.StateBeforeAway`) and `RestoreFromAway` is the one way back; going from one away state to another keeps the earlier memory. Six tests. - **One window event that threw lost every event queued behind it.** The events had already been taken out of the ring, so an exception in handling one dropped the rest of the batch - the `Destroyed` for a window that had just gone included, leaving a node in the tree for a window that was not there. Each event and each keyboard chord is now handled on its own and a failure is logged with what it was handling. The concrete thrower: a rule regex that ran out of its 100 ms timeout threw `RegexMatchTimeoutException` through the rule engine and out of the drain, so the window it was matching was never managed. A pattern that times out is now read as not matching and named once in the log as one that backtracks badly. - **The bar's and the palette's window procedures failed in silence.** Both caught everything - an exception escaping the callback would end the process - and said nothing, so every paint, click and keystroke that failed left no line in the log. Both log at error with the message concerned (`LogCategory.Ui`). `BeginPaint` is now paired with `EndPaint` whatever painting does: a throw between the two left the update region unvalidated and Windows posted the paint again at once, for ever. The composited renderer takes the window's DC after the call that can fail rather than before it, so a failed frame no longer leaks a DC, and creates both of its surfaces or neither - a glyph surface that failed after the frame succeeded was never tried again and drew a bar with backgrounds and no words. - **The "this window is not being managed" row was appended to every frame.** Inside an action list, a confirmation or a report the palette's mode is whatever the list underneath was in and the filter is empty, which is exactly the state the row is derived from - so a palette opened from an unmanaged window offered it under every Yes/No, and Down-Down-Enter in "Close it?" opened an inspect report. It was also placed last, with the diagnostics, while its own remarks promised the top. Derived rows are not added inside a frame (`PaletteModel.Overlaid`), and a derived row that leads somewhere goes above the matches like one that runs something. - **A workspace with a space in its name did nothing from the palette or the bar.** Names were written bare into the commands a row runs - `focus --workspace Second Monitor` - and the window manager's tokeniser split them; a workspace called `'` opened a quotation that never closed. Macros were validated as tokens and stored as tokens joined with spaces, so `focus --workspace "Second Monitor"` in an `action` passed the check and failed at the keystroke; the bar quoted with double quotes only, so a workspace called `"` became an empty name. Everything that builds a command from a name it did not choose now spells it through `CommandParser.Quote`, which is the tokeniser's inverse: the palette's rows, its completions, its macros (placeholders included, and not doubled when the file already quoted around one), the bar's click commands, and `shubbak` itself, whose arguments had already been unquoted by the shell. A name holding both kinds of quote has no spelling in this command language and the loader now says so (`SHB0454`). - **Escape out of a frame put the selection back at the top.** Ctrl+Enter on the twelfth window, Escape, and the first window was selected. The frame remembers the row it was opened from and the selection goes back to it, or to the row that stands for the same window in a list rebuilt meanwhile. - **Two rows that differed only by window could swap places between keystrokes.** The order ended at the title, and the sort is unstable; two untitled Notepads never focused traded places. The command, which names the handle, is the final tie-break. - **The bar took its snapshot before subscribing, and lost what happened between.** A focus change or a workspace switch in that gap was in neither the snapshot nor the stream, and the bar showed the wrong workspace until something unrelated happened. It subscribes first (`IpcClient.BeginSubscriptionAsync`, the handshake half of `SubscribeAsync`) and reads the snapshot second; the events queued between the two are applied on top and are idempotent. - **A reload could leave a bar on a profile from the configuration it replaced.** The connection's pump thread picked the profile the moment a workspace or context report arrived, and the loop swapped the selector on a reload; the pump could finish choosing from the old selector after the loop had installed the new profile. A report is now recorded on the bar as one value and the loop picks the profile, after any reload, on its next pass. - **A pipe request whose handler threw closed the connection with no reply and no log line.** The client waited out its ten-second timeout and the daemon's log said nothing, because the failure never reached anything that logs. The request is answered with the failure, the connection stays, and the daemon logs it (`IpcServer.HandlerFaulted`). A message that deserialises to `null` is answered as malformed rather than ignored. - **A megabyte of `{` over the pipe could end the process.** The KDL parser is recursive and `add-rule` parses client text; under NativeAOT a stack overflow is the process ending with every managed window stranded. Blocks nested deeper than 64 are refused with a diagnostic (`SHB0014`) and skipped whole, braces counted, so what follows still parses. - **The watcher read a refused subscription as a lost connection.** A window manager that did not publish a topic this build asks for made Ayn drop and re-hold every lease once a second, for ever, with one debug line per second. It is said once at warning and asked again every thirty seconds, as the palette already did; a pipe that closed under the subscription is logged rather than reconnected in silence. - **Turning `microphone { muted #false }` off also disabled the mute button.** Core Audio was opened only if the mute *fact* was wanted, so a file that said not to report the mute lost the mute *signal* as well - `signal ayn microphone mute` said there was no microphone. The two are separate: the signal opens Core Audio when it needs it, and a reload that starts wanting the fact opens it too, where before the endpoint was opened once at startup and never again. - **`ayn --report` shows when each program opened and closed the device**, which is the one question a report is asked: why the watcher thinks a program that crashed a fortnight ago still has the camera. A start after the last stop is read as open, in case a build of Windows leaves the old stop in place; and a missing user hive is a warning, since programs running as the user write nowhere else. A file that does not parse at startup is named in the watcher's log as the file it is running its defaults against. - **An application launched from an empty workspace opens on that workspace, not on the other monitor.** Switch to an empty workspace on one display, open a launcher - PowerToys Command Palette, Dalil, the Start menu - start Edge, and more often than not Edge appeared on the workspace displayed on the other monitor. The keyboard has to be somewhere while a workspace is empty, and Shubbak parked it on the desktop. When the launcher hid itself, Windows handed the foreground to the window that had it before the launcher - and Windows never chooses the desktop for that, so it chose the first application window it found: the one on the other monitor. Shubbak followed that as a focus change, the point of action moved across, and the new window was placed where focus now was. Measured cross-process on a live desktop, with the desktop losing every time; the earlier fix only ever worked with one monitor, where every other candidate was concealed. The keyboard is now held instead by an invisible window of Shubbak's own - zero pixels, owned so it is on no taskbar and in no Alt+Tab list, placed on the monitor whose workspace is empty - and that is the window Windows hands the foreground back to. `Shubbak.Native.FocusSink` is the new piece; the filter refuses its class by name, the hook never reports it, and it declines Alt+F4. The same window also catches the last window on a workspace closing, hiding or minimising after it took the foreground from the sink, which used to move the point of action to the other monitor by the same route; minimising is why it is an owned window rather than a tool window, since the walk Windows performs for a minimise skips tool windows and does not skip owned ones. Alt+Esc walks the same way and so reaches it; when it does, the sink passes the keyboard straight on to the window Alt+Esc was headed for and drops to the bottom of the order, so no press is lost to it. Telling that cycle from a fallback needed measuring, three times. The window being deactivated, which `WM_ACTIVATE` is documented to carry, is null across threads; `SC_NEXTWINDOW` through `DefWindowProc` does nothing when asked from a window of our own; and `WM_ACTIVATEAPP`, which does name the other side, names the thread that had the keyboard rather than the one that owns the window - for the Windows 11 Notepad those differ, and the keyboard's thread owns nothing on screen, so a real Alt+Esc from Notepad was read as a fallback and kept. What is exact is the window itself, and the window manager already knows it: the hook records the last window the system reported taking the foreground, at the moment the event arrives, and the sink asks for it. A cycle has Alt held and that window still on screen; a fallback has it gone, hidden or minimised whatever the keyboard is doing; and it is judged on that window alone, not its thread or process, because Alt+F4 on one browser window while another is open on the other monitor is a fallback too. The walk itself skips what Windows' own skips, cloaked windows included, so a cycle passed on never lands on a workspace Shubbak has concealed. While the sink holds the keyboard, a command that acts on the focused window is answered from the tree rather than refused as aimed at an unmanaged window - so `toggle-minimized` brings back the window it put away, as it promises to. `general { empty-workspace-focus "hold" }` is the default; `"desktop"` keeps the previous behaviour for anyone who would rather Shubbak owned no visible window (SHB0453 for anything else). The sink is created on first use, so that setting never creates it, and it is hidden while paused or suspended so that a window closing then behaves as it would with no window manager. Eighteen tests against real windows on real threads pin the behaviour - a real Alt+Esc passed on, a real Alt+F4 not, and a characterisation of the desktop losing that will fail the day Windows changes its mind - and five more cover the setting. Measured on the live desktop against Notepad as well: launch, Alt+Esc, minimise and restore, each once. - **Minimising the only window on a workspace no longer brings it straight back.** The tree keeps focus on that window on purpose, so that the key which put it away brings it back; the layout pass read that as a window to raise, and raising a minimised window restores it. It happened whenever the foreground had gone somewhere the pass may take it from - the desktop on one monitor, the focus sink on two, where the window had opened on a workspace entered empty and so went back to the sink every time. A minimised focused window is now nothing to raise, and the keyboard rests where it rests for an empty workspace. Three tests state the rule. - **The window manager can take the foreground from a UWP window.** `AttachThreadInput` refuses the thread behind `ApplicationFrameHost` - Settings, the Store, Media Player - and `SetForegroundWindow` then refuses too, so a focus key, a workspace switch or the focus sink asked to act while such a window was in front did nothing. The palette met the same wall first and got past it by providing one input event - a key-up of `VK_NONAME`, which nothing handles - since a process that has just provided input may take the foreground; `WindowActions.Focus` now does the same, only after a refusal, so the ordinary case costs nothing extra. It also attaches input only across processes, which is where the restriction lives. Measured by running the sink's tests with Settings in front: sixteen of sixteen, where the first step used to be refused. - **A window dragged onto a fibonacci, grid or master-stack workspace is tiled by that layout, not by where the mouse let go.** Dropping beside a window wrapped the target in a manual split whose axis came from whichever edge the cursor was nearest, which is right inside `splith` and `splitv` - the tree is the layout there, and a drop across the axis is how nesting is asked for - and wrong everywhere else, where the layout decides the geometry from the order alone and a hand-made split has no business being. Worse, when the workspace had held a single window, flattening then made that split the workspace's layout: a window dragged onto a `fibonacci-v` monitor turned it into `splitv` or `splith` depending on the drop, and the layout chosen for that monitor was gone until somebody set it again. In an automatic layout the edge now decides only the order - leading edge before the target, trailing edge after - and the layout draws the result, exactly as the keyboard's `move` has always done; `splith` and `splitv` are unchanged. The docs now also say which way round *horizontal* and *vertical* run, since `fibonacci-v` stacking two windows top and bottom is the layout doing what its name means, not the bug. - **A long window title no longer squeezes the clock.** The bar's layout shared overflow out proportionally among every child, so a title longer than the centre zone took width from the readouts beside it - and the guard against that was `truncate:90`, a character cap that knew nothing about the bar's actual width. Overflow now comes first out of the children that grow, which are the ones that stretch when there is room, and falls on the rest only once those have nothing left. The title fills what is free, is cut with an ellipsis exactly where the clocks begin, and the cap can go. - **The palette's first opening showed its frame and hid it again.** On a fresh session the first `alt+shift+space` drew the palette for a frame and put it away; the second press worked. The loop that puts away a palette nobody can reach ran straight after the call that showed it, and a foreground switch that had not landed by then - which on a desktop settling after logon is most of them - was read as a palette stranded. A palette now has half a second of opening grace: inside it, a foreground that is missing or lost is retried, never given up on, whatever `close-on-blur` says; past it, a palette that had the keyboard and lost it was *left*, which `close-on-blur` decides and which is never fought over, and one that never had it is stranded and is put away with a warning naming what stood in the way. The list is also read as soon as the palette connects, so the first opening shows the commands rather than an empty box for its first tenth of a second. - **The palette could not take the keyboard from a UWP window.** `AttachThreadInput` refuses a UWP frame (`ApplicationFrameHost`, error 5) and `SetForegroundWindow` then refuses too: seven attempts and a give-up at half a second, every time Settings or Media Player was in front. When the attached route is refused, the palette now sends one key-up of a key that means nothing (`VK_NONAME`) and asks again - the foreground lock yields to a process that has just produced input - and takes the keyboard in about twenty-five milliseconds. The log says when a nudge was needed. - **A layout pass took the keyboard back from the palette a second after it opened.** Every pass ended by bringing the tree's focused window to the front whenever anything else had the foreground, and on a desktop still settling after logon there is a pass a second or two in: Taj's second bar registers its strip late and the work area shrinks under it. That pass took the foreground from the palette - and would have from an application's dialog, the Start menu or an elevated Task Manager - and the palette read it as the user having left. A pass may now take the foreground from anything only when it is serving a change of focus - a workspace switch, a focus key, a window arriving or leaving, which is how such a window is meant to be left - and otherwise only from nothing, from the shell, or from a window Shubbak manages. At trace it says whose foreground it left alone. Four tests state the rule. - **A palette put away by a blur said nothing.** Both routes - the deactivation message and the loop's own check - logged at debug, so a report that the palette "closed by itself" had no evidence at the default level. Both now say at info who has the foreground and how long after opening. - **Releasing a window on another workspace left it cloaked, with no way to reach it.** A window on an inactive workspace is cloaked by Shubbak. Letting go of it - which a rule does at reload when it says `ignore` - forgot the concealment without undoing it, leaving a process running with no window anywhere: not in Alt+Tab, not on the taskbar, not on any workspace. Releasing by hand never hit it, because the foreground window is by definition on show; found the first time "Ignore it" was tried on a window that was somewhere else. The release now undoes exactly what Shubbak did to the window, minimised or not, and nothing else - a window its application hid itself is released without being fought over. Three tests on real windows. - **An unreadable config file was an exception, not a diagnostic.** `LoadFile` read the file unguarded, so a reload while an editor still held it would have thrown into the message loop. It now reports `SHB0400` with the reason, as a missing file always did - which matters rather more now that a save is what triggers a reload. - **The hint bar under an expanded rule said only `Esc back`.** Inside a frame the bar asked the selected row whether it had anything to copy, and the rows of an expanded frame - the lines of one value - carry nothing, so the one frame that exists to be copied from was the one frame that showed no way to copy. It now reads `⌃C copy line` `⌃⇧C copy all` `Esc back` there, and `⌃↵ actions` on any frame row that carries a list Enter does not open. The decision moved out of the renderer into `PaletteInput`, where it is tested against what Enter actually does. - **Taj left a `kind="command"` source's program running.** Disposing the source cancelled its reader and dropped the handle, and the program - usually a PowerShell script - carried on. That happened at exit and on every configuration reload, since a reload replaces every source, so a bar reloaded five times had six copies of each script writing to nothing. The source now stops the program and everything it started when it is disposed, which is a tree kill because the program is nearly always `pwsh -File` or `cmd /c` something, and on Windows ending a parent leaves its children running. Two tests: one that a grandchild is gone after dispose, which failed on the old code, and one that a program that exits is still started again. ### Internal - **One test runs the real window manager.** `Shubbak.EndToEnd.Tests` starts `shubbak-wm.exe` as a user would - a config, a state directory and a pipe of its own - opens `winver` beside it, and asks the daemon over the pipe what it did: managed, tiled by the config's rule into most of its monitor, visible and uncloaked, one window on the workspace, floated and tiled again on command; then `shubbak stop`, and the window is still there uncloaked, the session names it and the log has no errors. A second test starts it on a file that will not parse and finds it up and answering. Two seconds; every wait bounded and naming what it waited for; every process it starts has its output drained rather than inherited, since a leaked child holding the test host's pipes is how the first run wedged `dotnet test` for as long as it was allowed to. It refuses to run beside a window manager of the user's, as the native tests do, and is marked `Requires=Foreground` so the ARM64 job leaves it out with the focus-sink tests. Twenty-five hundred unit tests were green when its first run found the rule-dispatch bug above. - **`SHUBBAK_STATE_DIR` and `SHUBBAK_INSTANCE`.** Six places each asked the shell for `%LOCALAPPDATA%` and appended `Shubbak`; they now ask `ShubbakPaths.StateDirectory`, which honours the first variable - setting `LOCALAPPDATA` does not move a .NET program's known folders, which the first end-to-end run learned by saving the test daemon's session over the author's. The second appends a name to the pipe, the single-instance mutexes and the stop events in the one place they are built, so a second Shubbak can run under one account and a command line started with the same value finds it and no other; `IpcProtocol.PipeNameForInstance` says where. Both read once, documented in `docs/scripting.md`. - **The signal and shutdown payloads are read with `Utf8JsonReader`, not a document.** Both were two fields parsed through `JsonDocument`, which the bar had never linked; reading a signal there would have pulled the document machinery into a binary that had done without it, and measured at fifty kilobytes for two fields. The reader is in every companion already, underneath the generated serialiser. The palette shrank by twenty kilobytes for losing the document; a number written bare in a signal still arrives as a word, and a nested value as its JSON, as before. `CommandParser.Quote`'s remark now admits the one value it cannot spell: the empty string, which the tokeniser reads back as no token at all. - **CI authenticates the WinGet client update.** `tools/ensure-winget.ps1` updates the runner's client through Microsoft's module, which asks GitHub's API for the release and its assets - anonymously unless a token is in the environment, and anonymous calls share a rate limit with every other build on the runner's address. One run failed on that alone. The build and release workflows now pass the workflow token, as the winget job already did, and the script warns when it has none. Checked against the module's released source: the token goes to `api.github.com` alone, the downloads carry no credentials, and the runner masks the value in the log. The build workflow now states `contents: read` itself rather than inheriting it, and the module is installed at a pinned version, as the actions are pinned by hash. - **The consent-store tests no longer assume what the machine has.** Two of them asserted that Windows's store has a `graphicsCaptureProgrammatic` leaf, which a build agent that has never shared its screen does not, and CI was red for four runs before anyone read why. They now build a store of their own under a scratch key, with the leaves each test needs - which also lets them prove the reload path end to end: a leaf watched after the store was built fires its own event when a program starts using the device, and the read names the program. One test still looks at Windows's store, checking the store's answer against the registry's own rather than against a developer's machine. - **A racy message-loop test.** `PostedWorkRunsOnTheLoopsThread` read the loop's thread id from a field the loop's pass wrote, but a turn drains the inbox before it runs the pass, so work posted before the loop's first turn - the thread not yet scheduled, which a busy parallel test run makes likely - ran before the field was set, and the test failed about once in three full runs. Shown deterministically by posting before the thread started; the test now awaits the pass as well. - **CI regenerates the diagnostics catalogue and fails on drift.** `docs/diagnostics.md` is produced by `tools/list-diagnostics.ps1`; the build now runs the script and compares, so a code added without the page being regenerated is caught at the pull request rather than by a reader. Checked by planting a code: the step fired and named the fix. - **The three companions share one host.** The bar, the palette and the watcher each carried their own copy of the same plumbing - the help-and-version preamble, the single-instance lock, the log file named after the program, the DPI opt-in, a window class with a window procedure that must never throw, a message loop that waits rather than polls, and a subscription that reconnects for as long as the process lives - and no two copies agreed: three connect timeouts, one pre-check for the pipe, one that treated a refused subscription as a lost connection, one that logged a pipe closing quietly and two that did not. `Shubbak.Companion` is the one copy: `CompanionBootstrap` (the start, in the order the lock-before-log bug taught), `CompanionWindow` (the class, the procedure, `EndPaint`-safe painting, pointer tracking, the session ending, `WM_DPICHANGED` and the accent broadcast), `MessageLoop`, `EventPump` (subscribe, then snapshot, then read; refused is not lost; the pipe looked for before it is connected to; a give-up clock for the bar) and `SignalPayload`. Taj's host went from 2,900 lines to 2,000, Dalil's and Ayn's in proportion, and each program's CsWin32 manifest now names only what is its own. Behaviour-identical by intent and verified live: bar reserved on both displays, palette opened by signal and put away on blur, watcher re-holding after a restart. - **The hosts have tests.** `Shubbak.Companion.Tests` drives the pump against a real pipe - the event published during the snapshot arrives, the server going is a `Closed` and the next one is found, a refusal is a `Refused` and not a loss, giving up ends the pump - and the loop on a thread of its own. `Shubbak.Ipc.Tests` is where the protocol's twenty-one tests now live, out of `Shubbak.Wm.Tests`, where they had made the one project that owns the pipe the one with no tests of its own. `Taj.Tests` covers the bar's geometry and the keyboard-layout stepping; the snapshot projection - which monitor's layout, which workspaces, what order, what a JSON null becomes - moved to `Taj.Core` as `SnapshotProjection` and is tested there. `Ayn.Tests` drives the watcher's two connections through lose, reconnect and re-hold. Forty-nine new tests in four new projects; the total is checked by CI as before. - **CI collects coverage.** One Cobertura file per test project, summarised per assembly in the log by `tools/summarise-coverage.ps1` and uploaded as an artefact. Not gated, on purpose: the figure exists to be looked at when deciding where the next test belongs. The first run says what the review said - the libraries at 84 to 96 percent, the executables at 2 to 21. - **`IpcClient.PipeName`, `IpcServer.PipeName` and `IsServerRunning(pipe)` are public**, so a client outside the assembly can be pointed at an isolated pipe. The pump takes one for the same reason. - **The message-loop timing tests run the test host the way the daemon runs itself.** `MessageLoopTests.AShortTimeoutStillRunsWithoutBeingWoken` failed on a Windows Server 2025 runner - once in seventy-four runs - with exactly 20 passes in 300 ms at a 7 ms timeout: the system tick, with `timeBeginPeriod(1)` held and accepted. Since Windows 11 a process nobody can see or hear gets no guarantee its request is honoured; measured on 26200, a process with no window at all keeps the fine resolution for about two and a half seconds after asking and then loses it, for good, while `NtQueryTimerResolution` goes on reporting 1 ms because something else on the machine holds it. The daemon opts out of that heuristic at startup through `PowerThrottling.OptOut`; the test host only did so when `PowerThrottlingTests` happened to run first, and xUnit orders classes at random per run. `MessageLoopTests` now opts out itself, and the assertion reports whether the fine timer was held, whether Windows honoured it, and how late the waits ran. The same measurement gave the test that `PowerThrottling.Apply`'s remarks said could not exist: switch the mechanism on by hand, watch the waits go coarse, call `OptOut`, watch them come back - which fails with the state mask inverted, where the six existing tests all pass. - **The winget submission is made with `wingetcreate` rather than `winget-releaser`.** The repository refuses any action not pinned to a commit, and the rule reaches through composite actions: `winget-releaser` fetched Komac through an action of its own pinned to `@main`, and the job was refused before its first step - on the first release it was ever asked to publish. `wingetcreate`, Microsoft's tool, is now a plain download at a pinned version, checked against the hash Microsoft publishes and against its signature before it is run with the token, and it files the manifests the release build already produced. Nothing derives a manifest from the previous version any more, so the first version is submitted the same way as every one after it, and the by-hand first submission is retired along with the `WINGET_FORK_USER` variable - the tool works as whichever account the token names. ## [0.10.0] - 2026-09-12 Everything since 0.9.0, which was tagged and never published - so this is the first release anybody can install. In six parts, newest first: connecting to the window manager, how it is installed, then contexts and the watcher, validation, the palette as a control surface, and inspection from the palette. ### Connecting to the window manager #### Fixed - **Closing an IPC connection could throw, and Dalil read one of the throws as a refused subscription.** `IpcClient.DisposeAsync` disposed its writer, disposing a writer flushes, and a flush asks the pipe how it is. A client whose last request had failed because the window manager left got `IOException: Pipe is broken`; a client disposed while its last write was still being accounted for - the bytes long since in the pipe and acted on, the task that wrote them not yet run to its end - got `InvalidOperationException: The stream is currently in use`. Every caller had grown a different catch around it. Dalil's caught the second as "the window manager refused the subscription", warned that `shubbak-wm` was probably too old, and backed off for thirty seconds. The client now disposes the pipe and nothing else, since the writer never holds anything back, and its pending operations fail where they were awaited. Found by `IpcSubscriberGateTests.OnlyTheTopicsAskedForCount` failing one run in ten on the ARM64 runner; reproduced one run in eighty on a loaded x64; now three tests that build each state by construction and failed every time on the old code. - **A request sent while a subscription's handshake was still in flight was let onto the stream.** Sending on a subscribed connection is refused, but the flag that says so was set after the handshake, and the handshake holds the turn - so a request that queued behind it found the connection not yet streaming, waited its turn, and was then let onto a stream the subscription had started reading. The two raced for lines; whichever lost threw the right exception for the wrong reason, or read the other's answer. The handshake now claims the stream before letting go of the turn, and the check is repeated under it. - **Taj said nothing when the window manager's pipe closed under it.** A read that meets the closed end of a pipe reports the end of the stream, not an error, so the pump loop ended, went round and reconnected without a line in the log to account for the gap. It now says so. The clean case - a shutdown notice, then the close - was already logged from the notice. #### Internal - **CI prints why a test failed.** `dotnet test --verbosity quiet` quiets the console logger too, and at quiet it prints a failing test's name and nothing else - which is how a failure on the ARM64 runner came to be undiagnosable from its own log. The console logger is now named at `minimal`, which prints failures in full and passes not at all. ### Installing it #### Added - **ARM64.** Every release now ships for x64 and for ARM64 - an MSI and a zip for each - and winget and Scoop pick the one for the machine. The code had no architecture-specific paths; what had pinned it to x64 was `Shubbak.Native`, the one project CsWin32 must compile for a concrete architecture. It, the executables and the test hosts now take their runtime identifier from one property, so the whole solution builds and tests natively on an ARM64 machine, and CI does exactly that on every push: the cross-compiled ARM64 binaries are run on an ARM64 runner and the ARM64 installer is installed and uninstalled there for real - which stands in for the ICE validation an x64 machine cannot perform on an ARM64 package. The x64 installer gets the same install-and-uninstall test. The performance numbers in ADR 0001 are still x64 numbers. - **An installer.** `shubbak--win-.msi`: the five executables under `%ProgramFiles%\Shubbak`, that directory on the machine `PATH`, an entry in Apps & Features and one Start Menu entry. It carries the **uiAccess** build of the window manager, which is what lets Shubbak move windows belonging to elevated programs - Task Manager, anything run as administrator - without itself running elevated. Windows grants that only to a signed program installed in exactly that place, so it could not be done with a zip. The installer starts nothing and registers nothing to run at logon; `shubbak autostart enable` remains the user's decision, and an installer running elevated is the wrong process to write a per-user Run key from. The config and the session survive an uninstall for the same reason. - **Code signing.** Every executable and the MSI are Authenticode signed through Azure Artifact Signing, with the release workflow authenticating by OpenID Connect so no key exists to leak. A tag build refuses to run unsigned. The certificate is new, so SmartScreen may still warn about a hand-downloaded file until it has built a reputation; winget installs are not affected. - **winget serves both.** One identifier, `MoaidHathot.Shubbak`, two installers: the MSI, which winget prefers when both apply, and the portable zip, which `--scope user` gets. Scoop stays portable, as Scoop is. - **`shubbak stop`.** Stops the window manager, the bar, the palette and the watcher, and waits until each is actually gone. The window manager goes first, over the pipe, so it saves the session and brings every concealed window back; the other three are asked directly, so this works with no window manager running. Non-zero if anything is still there after ten seconds, so a script replacing the executables can tell. Before this it was four commands and a look at Task Manager, because the palette and the watcher deliberately outlive the window manager. - **A clean exit when Windows asks for one.** The window manager's only window was message-only, which kept it out of `EnumWindows` and also out of reach of `WM_QUERYENDSESSION` and `WM_ENDSESSION`: at logoff it was simply killed, with up to thirty seconds of session unsaved and every concealed window left concealed for the next run to adopt. The window is now a hidden top-level one. The session is saved and the windows un-concealed inside the message itself, because the system may end the process the moment it returns. The bar and the palette answer the same messages. - **Upgrades in place.** A silent MSI install - what `winget upgrade` runs - closes what holds the files through Restart Manager, and the window manager now registers to be started again afterwards; its startup commands bring back the bar, the palette and the watcher. `MSIRMSHUTDOWN=1` in the package forces anything that ignores the request, so an upgrade never ends in "restart required". Scoop stops everything before an update (or the files could not be replaced) and does not start it again. - **`docs/getting-started.md`.** Install, first run, the keys, the three companions, upgrading, uninstalling, where things are - in the order a newcomer meets them. It ships in the zip and the MSI, and the package managers' post-install notes point to it. - **Release tooling.** `tools/build-release.ps1` produces everything a release ships - both builds of the window manager, the zip, the MSI, the hashes, the filled winget and Scoop manifests - locally or in CI, so a release can be inspected before a tag exists. `tools/prepare-release.ps1` sets the version everywhere it is written and dates the changelog; `tools/check-release-consistency.ps1` proves the copies agree, on every push. Publishing a release fills the manifests from the published assets and commits them, and opens the winget-pkgs pull request once the package exists there. #### Changed - **The starter config turns everything on.** `shubbak config init` now writes a config that starts the bar, the palette and the watcher, with a small section for each: workspaces, title and clock on the bar, the `paused`, `suspended` and `config` pills, a camera and microphone glyph while one is in use, the palette on `alt+space`, the layout cycle on `alt+shift+space`. A window manager with no bar and no palette was not the thing the readme described, and a newcomer who had to discover three more programs and how to start them had been handed a worse first hour than necessary. The keys are listed at the top of the file. The test that loads the starter now runs it through all four loaders and expects silence from each. - **`shubbak config init` says what to do next**, and where the annotated example actually is. "Beside this binary" was wrong for a winget install, where the executable on `PATH` is a symlink and the example is a directory away; the link is followed, and when the file is not there either the answer is its address in the repository at this version. The command also uses the same default location the window manager searches, rather than a copy of that logic. - **"No config file found" says how to make one.** The message listed everywhere it looked and how to point it elsewhere, and never mentioned `shubbak config init` - the only hint that did was reachable only through `--config` naming a missing file. The daemon's log line says the same, and that the defaults bind no keys. - **A bare `startup-command "taj"` means the `taj` beside the window manager.** It was a `PATH` search, which found nothing in the two cases that matter most: straight after an install, when the terminal the window manager was started from still had its old `PATH`; and with two copies on one machine, when it found whichever sorted first. A bare name is now looked for beside `shubbak-wm.exe` first, which also takes the direct launch path rather than the shell's. Anything with a directory in it is a path and means exactly what it says. The example config starts the three companions this way instead of through a function that existed only on the author's machine. - **`shubbak autostart status` compares files, not strings.** winget's portable install reaches the executables through symlinks in a second directory, and the registered path and the running copy could differ as text while being one file - which produced "this is not the copy that will start at logon" on an install that was entirely in order. - **The two builds of the window manager no longer share `obj` and `bin`.** The manifest is baked into the apphost and MSBuild did not treat the uiAccess switch as an input to it, so publishing with the flag and then without silently reused the first apphost. The release builds both from one tree, which is why the separation is in the project rather than in a note saying "clean before switching". - **`shubbak --help` opens with a GETTING STARTED section**, and the changelog's three interim headings between 0.9.0 and this release are folded into it as sections rather than versions nobody could install. - **The readme is a readme again.** What Shubbak is, what is in the box, how to install it and the first three commands - two hundred lines rather than nine hundred. Everything it used to carry moved into `docs/`, mostly word for word: [configuration](docs/configuration.md) (the file, rules, layouts, monitors, contexts, arrangements, commands), a page each for [Taj](docs/taj.md), [Dalil](docs/dalil.md) and [Ayn](docs/ayn.md), [scripting](docs/scripting.md), the [FAQ](docs/faq.md) and [architecture](docs/architecture.md) (why .NET, the layout of the repo, building). Every KDL snippet in the readme and the docs is now loaded through the real parser on every push, and a warning fails the build - the first snippet a newcomer copies must not be one the parser then complains about. The packages ship the whole `docs/` set. #### Fixed - **The tray's "Open configuration folder" did nothing for the two people most likely to click it.** With no config yet it logged a warning and opened nothing, although showing where to put one was its stated purpose; it now opens the folder `shubbak config init` would write to, creating it if need be. And it opened the folder through the command-line splitter, so a profile path with a space in it - `C:\Users\John Smith\...` - was cut at the space. - **The winget manifest listed `ayn.exe` in a zip that did not contain it.** The 0.9.0 zip had four executables; the manifest named five. winget's validation extracts the archive and checks every nested file, so that submission would have failed. The manifests are now templates the release build fills and checks, and every push validates them with the real client. ### Contexts, named monitors, arrangements and Ayn #### Added - **Contexts: named conditions on the desktop that layer overrides on the config while they hold.** A talk from the laptop alone, then on a projector, then docked to two monitors for a remote session, each wanting different gaps, a safer keyboard and the slides somewhere else - and none of it should need a hand on the config file. ```kdl contexts { context "presenting" { when { window app="powerpoint-slideshow" } when { system-state "presenting" } linger 500 gaps { inner 0; outer { top 0; right 0; bottom 0; left 0 } } window-effects { border #false } animation { enabled #false } bindings { bind "alt+shift+q" { } } workspaces { workspace ";" monitor="projector" } on-enter { focus --workspace ";" } } context "docked" { when { monitor present="dell-right" } } context "meeting" { } } ``` A context is level-triggered: active exactly while a `when` block holds, with no stuck state and no exit rule to forget. Within a block every condition must hold; several blocks mean any one is enough; `!` negates. Several contexts hold at once and cascade in declaration order, later winning, with the file as layer zero. A context whose conditions have just stopped holding lingers a moment before letting go, because PowerPoint creates and destroys several windows while a show starts and a context that flapped with them would run its on-enter and on-exit twice. The conditions are a closed set, on purpose: a window present, focused or full-screen - matched with the same `app` definitions rules use, or inline matchers; a workspace active or focused; how many monitors, which declared ones, the Win+P topology; remote session; the shell's notification state. Every one is something the window manager already knows in order to place windows, or something Windows says about the session in one cheap call. **Shubbak observes the desktop, not the applications.** Whether the camera is on, whether a call is up, what the calendar says, arrive from outside as a context with no `when` - external - that another program sets over the pipe. What a context can change: `gaps`, `window-effects` and `animation`, each read by the same code as the top-level section and applied as a delta, so `gaps { inner 0 }` changes the inner gap and nothing else; `bindings`, laid over the default table with an empty binding disarming a key; `rules`, consulted only while it holds; `workspaces`, re-homing a declared workspace while it holds and sending it back after; `on-enter` and `on-exit`, run once each way. Rules scoped to a context apply to events after it activates; `on-enter` is the place for bulk actions. The order on a flip is fixed: on-exit under the old configuration, apply, announce, on-enter under the new. Checked at load as far as load can check: an unknown condition is named with a suggestion (`SHB0446`), a reference to an undeclared app, monitor or context is an error (`SHB0447`), a bad value - a topology that is not one of the four, a state that is not one of the five, a `window` that names no window - is refused with the accepted list (`SHB0448`), an empty `when` is an error rather than always-true (`SHB0449`), a context that would depend on itself by any route has the closing reference dropped (`SHB0450`), and a `context` command naming an undeclared context is a warning (`SHB0451`). Reading the three sections as deltas also fixed a quiet asymmetry: `window-effects` was rebuilt from scratch rather than layered like the other two. - **`context --set|--clear|--toggle|--auto [--ttl 5s] [--lease]`.** A pin beats the conditions either way; `--auto` hands the context back to them. `--ttl` takes a pin off by itself after a while, so a script that polls every few seconds and then crashes leaves nothing behind; `--lease` takes it off when the connection that made it closes, so a process holding a pipe open supplies a fact for exactly as long as it is alive. That pair is the extension primitive this whole feature is arranged around: the daemon never learns the word "camera", and the program that watches the camera never learns what a meeting should do to the desktop. The command line refuses `--lease`, since its connection closes at once, and says what to use instead. - **`context.changed` on the event stream, `contexts` on the snapshot, `query contexts` and `shubbak contexts`.** The report answers "why is this context on" or "why is it not" the way `inspect` answers "why is this window not tiled": every block, every condition, whether it held and what it saw - `no such window`, `2 attached`, `the shell says ordinary` - and for a pin, who made it, how long ago, when it expires, whether it is leased. `shubbak status` names the active contexts, the tray tooltip shows them, and `diagnose` has a section. - **The bar and the palette know about contexts.** A bar `rule` takes `context=` beside `workspace=` and `monitor=`, so `rule use="presentation" context="presenting"` slims the bar down for a talk however the talk was detected; every attribute on a rule has to hold, and a rule on a context the `contexts` section does not declare is pointed out at load (`TAJ0020`) rather than silently never matching. `{{ contexts }}` names the contexts in force, joined the way `shubbak status` joins them, and is empty when none are. The palette's search box shows them in its pill, quieter than a binding mode and much quieter than "offline"; typing `context --toggle ` completes the declared names; and a `param` can ask `from="contexts"`, so one row toggles any of them. Both processes receive `context.changed` and re-read the snapshot, which lists the contexts in the window manager's own order. - **Ayn, the eye: a fifth executable, and optional.** It supplies three facts, each a context the window manager holds while the fact is true, with `--lease` so the pin dies with Ayn's connection and a crashed watcher leaves nothing behind: `camera-in-use` and `microphone-in-use`, from the record Windows keeps of which programs have a device open - the same one the privacy indicator in the tray reads - and `microphone-muted`, from the default microphone's mute switch - the one the Sound settings toggle. Facts are named `subject-state`, so the ones about one device sit together and the next device slots in. The file says what a meeting does; Ayn never needs to know. ```kdl contexts { context "camera-in-use" { } context "microphone-in-use" { } context "microphone-muted" { } context "meeting" { when { context "microphone-in-use" } ... } context "meeting-muted" { when { context "meeting"; context "microphone-muted" } } } ayn { camera { in-use "camera-in-use" } // camera #false: say nothing about it microphone { in-use "microphone-in-use"; muted "microphone-muted" } settle 500 // ms a change of use must last } ``` It also acts, on one thing: `signal "ayn" "microphone" "mute" | "unmute" | "toggle-mute"` from a keybinding, the bar or the palette flips the system mute, and the endpoint's own change notification turns that into the context - the same path a change made in the Sound settings takes, so there is one copy of the truth. That is the system's mute; a call's own mute button is the call's and invisible from here, but Teams and its kind notice this one and say "muted by your system". It sleeps on `RegNotifyChangeKeyValue` and two Core Audio callbacks and holds no timer between changes, except while a change of use is waiting out its settle time - a call opens and closes the camera several times while setting up, and a context that flapped with it would run its on-enter and on-exit twice. The mute is never settled: a person who pressed the key wants the icon now. When the window manager restarts, Ayn notices and holds again within a second. Every setting has a default, so the section can be left out; `check-config` reads it (`AYN0001`-`AYN0005`, warnings only, the last for a context the file names but does not declare). `ayn --report` prints what Windows says about each device right now, and opens no log file; `shubbak ayn-exit` stops it, through a named event since it has no window to close. Renaming a context under a running watcher on `wm-reload-config` hands the old name back and holds the new. It exists as much to prove the pipe as to detect meetings: everything a provider needs turned out to be one held connection sending two commands, and the one thing it needed twice - a second connection, because a subscribed one cannot send - is noted in the design note as the pipe's remaining rough edge. - **One bar widget per context, and an icon font for it.** `{{ context.meeting }}` is `meeting` while that context holds and empty otherwise - one source per context beside the joined `{{ contexts }}` - and the new `then:X` filter turns a non-empty value into `X`, so `{{ context.camera-in-use | then:\u{E722} }}` is a camera glyph that appears while the camera is on and leaves with it. A widget, or a `when` block, can name a `font=` of its own, which is how one widget draws from Segoe Fluent Icons beside text in the profile's face; with `on-click="signal ayn microphone toggle-mute"` that widget is a mute button. A template or `when of=` reading a `context.x` the file does not declare is pointed out at load (`TAJ0021`), with a guess. - **Every clickable widget on the bar now admits it is one.** The workspaces had a hover response and a hand cursor had nothing; the pills for suspended, paused, a broken file and a binding mode, and the new camera and microphone glyphs, sat there looking like readouts. Any `text` widget with an `on-click` now lights up under the pointer - a pill lightens towards white by a fifth, a bare glyph gains the same faint pill the workspaces use, derived from whatever style the widget is showing at that moment so a red pill hovers red - or takes `hover-background` and `hover-colour` of its own (`TAJ0022` if given to a widget with no `on-click`, since a readout that lit up would be claiming to be a control). The pointer becomes a hand over anything clickable, workspaces included, and an arrow over everything else. - **Saved arrangements: `arrangement --save|--restore|--delete `.** A demo whose windows have been dragged about wants them back where they were. `--save` records the focused workspace's tree - the containers, their layouts, their ratios, and which window sits in each leaf - which is exactly what the session file deliberately does not: the session file says which workspace a window belongs to and is applied while windows arrive in whatever order Windows enumerates them; an arrangement is asked for by name when every window is already here, and so can record the shape. Windows are recorded by process and class, with the title hashed and the path kept, as the session file records them and for the same reasons. Restoring rearranges only the windows on the workspace, and only the ones that tile: a recorded window that is not open is left out and its share goes to its siblings, a container left with one child becomes the child, and a window the arrangement never knew stays after the rebuilt tree with the share it would have had as one more child. Nothing is pulled in from another workspace, nothing is hidden. A restore that finds none of its windows is refused rather than quietly done. `shubbak arrangements` lists what is saved; `query arrangements` is the same over the pipe; `arrangement.restored` on the event stream says how many were placed, how many were not open and how many were kept; the palette completes the names and a `param` can ask `from="arrangements"`. The file is `arrangements.json` beside the session file, written the same atomic way. Parser codes `SHB0321` and `SHB0322`. - **`system-state "away"`.** The shell's "user not present" state - the session locked, the screen saver up, another user's session in front - was reported as `unknown`, the word for a probe that failed, until the tests first ran on a locked machine and said the probe had failed when it had answered. It has a name now, and a full-screen Store app is reported as `fullscreen-app` rather than `unknown` too. - **Monitors can be named by what they are, and workspaces bound to the name.** `monitor=1` on a workspace is a position in the order Windows reports displays, and Windows reorders that on replug, on DisplayPort wake and on a driver restart - which is how a workspace bound to "the right-hand screen" ends up on the left one after a dock. A top-level `monitor "name" { ... }` names the screen instead, by what the display configuration reports about it, with the same matchers and `!` negation an `app` has: `name` (the panel's EDID name), `path` (the connector's device path), `device` (the `\\.\DISPLAYn` name), and the flags `internal` and `primary`. ```kdl monitor "laptop" { internal } monitor "dell-left" { path *= "UID4355" } monitor "dell-right" { path *= "UID4357" } workspaces { workspace "/" display-name="Second" monitor="dell-right" } ``` The desk this was written on is why `path` is first-class rather than a fallback: two identical Dells, the same name on both, and only the connector's id in the path to tell them apart. `shubbak monitors` prints a definition for every attached display, ready to paste, matching on the shortest part of the path that no other attached display shares - so the hundred characters of hexadecimal never have to be typed. Checked at load, as far as load can check: a workspace bound to a name nothing declares is an error with a suggestion (`SHB0442`), a definition with nothing to match on is a warning (`SHB0440`), a misspelt matcher is named rather than dropped (`SHB0439`), and a setting on a workspace that is not one of `display-name`, `monitor` or `layout` - `bind-to-monitor`, GlazeWM's spelling, was the common one - is now reported (`SHB0428`) rather than silently ignored. Whether a definition fits a real display is the daemon's to know, and it logs each display's names at startup and each definition that fits nothing. `monitor="DISPLAY2"` is accepted for what it is worth - it used to be an error that told you device names had never worked. - **Workspaces go home when their monitor comes back.** Unplugging a display migrated its workspaces to a survivor, and always had; plugging it back in did nothing, because a workspace's preference was consulted when it was created and never again. Every dock and undock therefore ended with a round of moving workspaces back by hand, which is the chore a preference exists to remove. They are now put back whenever the monitors change and whenever the config is reloaded - a workspace that was on screen stays on screen, on the display it belongs to, and a hidden one stays hidden; focus follows only the workspace that held it. A workspace moved by hand stays where it was put until the next such event. - **`move-workspace --monitor `.** The command was direction-only. It now also takes a declared monitor name, a position counted from zero, or a device name (`DISPLAY2`, with or without the `\\.\`). Giving both a direction and a monitor is refused (`SHB0315`); a name nothing declares is a warning at load (`SHB0443`) and a refusal at runtime that lists what would have worked. The palette completes the argument from the names and positions the window manager reports. - **`shubbak monitors`.** Describes each display - position, device name, resolution, DPI, built-in or external, EDID name, connector path, what it is showing, and what the configuration calls it - followed by the `monitor` block above. The counterpart of `inspect`'s "write a rule for it". - **Three things the daemon knew and told nobody are now on the event stream.** Each was already being read, and each was acted on privately or written into the diagnostic report and nowhere else. Anything outside the process that wanted the same fact had to ask Windows for itself, which is the thing the event stream exists to make unnecessary. - `window.native_fullscreen` - an application took its own window full-screen, or gave the monitor back. A video, a slide show, a game in a window. It is an observation about the rectangle rather than a state: the window is still tiled or floating in the tree and is put back the moment the application lets go, so reporting it as `fullscreen` would have every client offer a toggle that cannot be toggled. `WindowInfo` carries it as `native_fullscreen`, appended and optional. - `wm.environment` - the session became remote or stopped being, or the shell's idea of what you are doing moved: `ordinary`, `presenting`, `fullscreen-app`, `fullscreen-game`, `quiet-time`. Both facts were already read every two seconds; the first turned animation off and the second appeared in `diagnose`. Published on change, and carried in the state snapshot as `remote_session` and `activity` so a client connecting between changes is not left guessing. The words are spelt out rather than derived from an enum, so they can be written in a config file later without anyone guessing how a member lower-cases. - `binding.fired` - a chord Shubbak claimed and ran, as `{key, mode, commands}`, the same shape as one entry of `query bindings`. **Bound chords only, and that is the design.** The hook sees every key on the machine; this reports the ones that were gestures at the window manager, so nothing typed into an application can reach it and a keycast overlay for a talk becomes a small external subscriber rather than a keylogger. Verb names rather than full command text, for the reason `query bindings` gives names: an argument can be a whole shell command line. Built only when someone is subscribed, so an unwatched desktop allocates nothing for it. All three are inert for the layout pass, and a test now names every event kind and fails when a new one has not been considered - which is how these were. The protocol version stays at 2: every wire change is a new topic or an appended optional field, and an older client reads exactly the JSON it read before. This is the first step of a longer piece of work, written up in `ideas/contexts.md`: the facts the window manager will later act on are the facts it now publishes, so whatever gets built on top can be built outside the daemon first. - **`UserActivity` moved from the platform layer to the core library.** It is published now, so the processes with no Win32 in them have to be able to name it. The bar kept a narrowed copy (`UserActivityKind`) for exactly that reason; the copy and the mapping between the two are gone. - **Monitors now know what they are, not only where.** `query monitors`, the state snapshot and the diagnostic report carry `friendly_name` (the EDID name, `DELL U3219Q`), `device_path` (the connector's device interface path) and `internal` (whether the panel is built in), read from the display configuration API and attached to the monitor node. The GDI name `\\.\DISPLAY1`, which is what everything keyed on and still does, is handed out in enumeration order and reused, so the same panel can be `DISPLAY1` before undocking and `DISPLAY2` after; the device path is the identity that survives that, and the comment on `MonitorNode.DeviceId` that called *it* a device path was wrong and has been corrected. Asked only when the monitor enumeration has changed - a dock, an undock, a cable - which is the only time the answer can differ, so the two-second poll pays nothing for it. Each monitor is logged once at startup with all three, so the path is one copy away from a config file. The Win+P arrangement (`extend`, `clone`, and so on) is read at the same moment and logged when it changes. Nothing acts on any of this yet; it is the ground the next step stands on, and a machine with two of the same monitor - this one - is already the case that proves the friendly name alone would not have been enough. Measured against the tagged `pre-contexts` build: `shubbak-wm.exe` grew by 56 KB, the other three by about 32 KB each. The idle tick is unchanged - about 4 Hz, tens of microseconds, nothing allocated at the median - and nothing periodic was added that could move it. `ideas/contexts.md` has the full table and the procedure. - **A palette action can ask a question.** A `param` turns one row into a picker, so a single entry stands in for one per workspace. Nineteen workspaces meant nineteen actions to name, write and scroll past - or, in practice, none of them, because nobody writes nineteen of anything for a thing they do twice a week. ```kdl action "Send it to..." { param "ws" from="workspaces" move --workspace "{ws}" } ``` Choices come `from=` a list the palette already holds for its own argument completion - `workspaces`, `layouts`, `binding-modes`, `scratchpads`, `directions` - so a prompt costs a lookup rather than a round trip. `values="a b c"` writes them out instead, which is the right answer for stashing: the list the window manager knows is the slots *currently holding a window*, and the slot you want is the empty one. Several `param`s are asked one after the other, each picker opening the next. That needed no new mechanism: a row with no command and children of its own is exactly what an action-list row already is, so the frame stack, Escape-goes-back and the breadcrumb all worked unchanged. Workspaces are labelled `3 — Code`. A picker reading `1`, `\`, `'` and `;` is not one anybody can choose from, and the display names were already being fetched. Checked at load time, in the config file's own words. A placeholder nothing declares is an error with a line and a caret - and it is the one mistake the parser cannot catch on its own, because `focus --workspace "{wsp}"` is a perfectly valid request to focus a workspace literally called `{wsp}`: it parses, loads, runs, and is refused at the far end of a keystroke. A question no command asks is a warning, and the unused parameter is then dropped rather than left to stop the row and collect an answer that provably goes nowhere. Directions are probed with a real direction before the parser sees them, so `move --direction "{d}"` is checked rather than waved through. - **`signal "palette" "run" ""` runs an action from a key.** The two halves of the configuration could not refer to each other: an `action` is a sequence of commands with a name and a `bind` is a sequence of commands with a key, so anybody who wanted both wrote the same three lines twice and the two copies then drifted. ```kdl bind "alt+ctrl+d" { signal "palette" "run" "Deep work" } ``` Nothing is shown on success - the point of putting an action on a key is that it happens, and opening the palette to announce it would put a window in front of the arrangement it just made. There is deliberately no verb for this and never will be: the window manager has no idea what an action is, carries the name without reading it, and this is the process that knows. An action that *asks* cannot be answered by a key, so those open the palette with the name already typed and the picker one Enter away; a name matching nothing opens the command list rather than doing nothing, because the key has already been pressed and silence is indistinguishable from a dead palette. - **Four more rows the palette answers itself.** `keys` reaches the key reference, which was previously behind `?` and `Ctrl+8` and therefore behind already knowing about them - a row is the one route that can be found by searching for the thing you want. `config path` copies the path of the file actually in effect. `actions` lists your own sequences on their own, away from the thirty-three verbs. And `reload palette` re-reads the `dalil` section alone, which matters precisely when the window manager refused the file: it announces a reload only when it *accepted* one, so a mistake anywhere else left the palette running on settings the file no longer contained, with nothing anywhere to say so. #### Changed - **Taj's bars follow the displays.** They were created once, from the displays present at startup, and that was the whole of it: plug in a monitor and it had no bar; unplug one and its bar stayed, reserving a strip of a display that no longer existed and filtering on a position that now belonged to a different one. A laptop docked and undocked once a day met both. Bars are now opened and closed as the window manager reports displays coming and going, and moved when a display changes shape or position - a resolution change, or the monitor to its left being unplugged so that its origin moves to zero. A bar identifies its display by device name rather than by position. Positions shift when a monitor is unplugged - every bar after it moved down one and started showing the wrong display's workspaces - and a bar whose window failed to create was skipped while its position was not, so every bar after *that* had been one slot off since startup. A bar `rule` can now also say `monitor="laptop"`, using the same names the `monitor` definitions declare; `monitor=1` still means the position, and both are read off the window manager's list rather than the bar's own enumeration, so the two halves of the file agree about which display is which. - **The session remembers each monitor's connector path** beside its device name, and restores the view by path first. A display that comes back under a different device name after a replug gets its own workspace back rather than whichever one the name now belongs to. Sessions written before the path was recorded restore as before. - **Taj subscribes to the topics it handles, not to everything.** It subscribed to `*`, which is the shortest thing to write and made the window manager serialise and send a payload for every event on the desktop to a process that dropped most of them: `command.rejected` alone fires on every repeat of a held key that cannot be satisfied. The daemon has a gate that only builds a payload when somebody is subscribed to the topic, and for as long as a bar was running that gate answered yes to everything - including anything added to the stream for other consumers, which the bar would then have paid for too. The list of topics now sits beside the switch that handles them, and two that were being dropped by the default case are handled: `workspace.moved` and `monitor.*` both change which workspaces a per-monitor bar should be listing. - **`move --workspace N --focus` replaces the `move; focus` pair.** "Send it there" and "send it there and go with it" were one command and two commands on the same key: ```kdl // before bind "alt+shift+{name}" { move --workspace "{name}"; focus --workspace "{name}" } // after bind "alt+shift+{name}" { move --workspace "{name}" --focus } ``` The pair reads well and is wrong, because its second half is indistinguishable from the bare workspace switch bound to the same key without shift. Press `alt+shift+1` while already on workspace 1 - the ordinary slip of aiming a move at the workspace the window is on - and the move correctly does nothing, leaving a plain `focus --workspace 1` for `toggle-workspace-on-refocus` to answer as a re-focus. The window stayed put and the screen jumped to the previous workspace, from a key whose whole subject was moving a window. Nothing downstream could tell the two presses apart. The daemon runs each command singly, so no layer ever sees the pair, and every fix that keeps the pair has to smuggle context from one command into the next. Saying the intention once removes the ambiguity instead of compensating for it - which is also what every neighbour does: i3, GlazeWM and komorebi all bind `mod+shift+N` to a single command, never a sequence. GlazeWM has the same fault in a worse form, where `move --workspace 2` while on 2 sends the window to the previous workspace ([glzr-io/glazewm#1397]). `--focus` is the per-binding form of `follow-window-on-move`, which remains the way to say it for every move at once - and which is now documented in the example config, having been accepted, validated and honoured since it was added without appearing in any file a user reads. Without either, `move --workspace N` still leaves focus behind, so the palette's "send it there and leave it there" is unchanged. Sequences still work and the old pair still parses; it is only no longer what Shubbak ships or what `shubbak config init` writes. `move --direction right --focus` is refused (`SHB0314`) rather than accepted and ignored: a directional move already carries focus with the window when it crosses to another monitor, and never loses it within a workspace. [glzr-io/glazewm#1397]: https://github.com/glzr-io/glazewm/issues/1397 - **Enter opens a prompting row's list.** A row with no command has always had its list behind `Ctrl+Enter` at the top level, because Enter there has to keep meaning "go to this window". A row that exists in order to ask is the exception: there is nothing else Enter could mean, and requiring `Ctrl+Enter` would hide the question behind a key nobody has a reason to press on a row that looks like every other macro. Without this it fell through to the "verb needing arguments" case and replaced the search with its own name. - **Taj waits instead of spinning.** The bar's message loop ran every 16 ms - sixty-two passes a second, forever - and almost every one found nothing had changed and did nothing. That poll existed only to notice a dirty flag being set from a timer or the pipe, which is what a wait handle is for, and the palette next door had been doing it properly all along. The model now says when it changes and the loop waits for that, with a one-second ceiling as a safety net so a signal nobody wires up later shows as a second of staleness rather than a bar that has quietly stopped. Loop passes per second, counted in the loop itself: **62.5 before, 1 after** while visible, and 4 while stood down - the latter being the rate at which a full-screen stand-down is re-confirmed, so it stays a quarter of a second. Measured as an A/B on one machine, same method, 150 seconds each, bar visible and untouched: **5.208 ms/s before, 2.708 ms/s after - 1.9x**. The loop was about half of what the bar spent; the rest is its pipe connections and its timer callbacks, which are unchanged. - **Suspending now means no wake-ups at all, rather than nearly none.** The loop woke once a second while suspended, on the reasoning that a wait has to end eventually and that an idle thread waking once a second is free. The second half was true and the first was not: every path that can end a suspension signals the pump - an IPC request wakes it explicitly, and the resume hotkey arrives as a thread message the wait already watches for - and the wake handle is an `AutoResetEvent`, so a signal raised while a pass is running is remembered rather than lost. Measured: **32 ticks in 30 seconds before, 1 after**, and that one was caused by the diagnostic call taking the measurement. `diagnose` no longer accumulates loop statistics while suspended, which is the one thing given up. - **Taj stands down when nothing on screen is showing it.** It kept polling and rebuilding behind a full-screen game, and while the window manager was suspended - which is what someone does *before* playing one. It now stops its interval and clock sources and widens its wait whenever either holds. Messages are still pumped, so the indicator saying why it stopped stays clickable, which is the way back that does not need the keyboard. The full-screen half deliberately does not trust `ABN_FULLSCREENAPP` alone, because it reports an opening and a closing rather than what is in front; the shell is asked again through `SHQueryUserNotificationState` on every slow pass, so a mistaken stand-down lasts a quarter of a second rather than until the application closes. Sources restart by taking a reading immediately rather than waiting out their interval, so a bar returning from a long game does not come back showing a stale clock. Together, measured over 150 seconds with the window manager suspended: | | before | suspended, after | |---|---|---| | `taj` | 5.208 ms/s | **0.104 ms/s** | | `shubbak-wm` | — | **0.000 ms/s** | #### Fixed - **The bar's hover highlight lasted half a second.** The tree is rebuilt on every model change - the clock, twice a second - and the hovered node belonged to the tree just thrown away, so the highlight went flat at the next tick while the pointer had not moved. The node is found again by id in the new tree. This affected the workspaces since the hover was added. - **The session file's title hash now survives a restart.** It was `string.GetHashCode`, which the runtime seeds differently in every process, so a hash written by one window manager could never match in the next and the tiebreaker it exists to be never broke a tie across a restart. It is a stable FNV-1a now, shared with the arrangements file. Session files written before this carry hashes that will not match once; nothing else about them changes. - **Taj lost its strip of screen when Explorer restarted, and every window tiled over the top of it.** Restart the shell - after a hang, or by hand - and the bar goes on drawing, updating and taking clicks while every window is laid out straight through it. It looks like the bar is behind the tiling; it is really that nobody reserved room for it any more. The shell owns the list of registered appbars, so restarting it forgets every one and hands the reserved space back. Taj was told nothing, because as far as the new Explorer is concerned it never registered, and Explorer does not send appbar notifications to windows it has never heard of. Taj's own `_appbarRegistered` still said yes, so even its existing re-assert path would only have sent `ABM_SETPOS` - addressed to nobody. Shubbak was not wrong either: it read a work area that genuinely did cover the whole monitor and tiled into it, which is exactly its job. Two correct components, and a message neither of them was listening for. Taj now listens for the `TaskbarCreated` broadcast - the same announcement Shubbak's tray icon has always used to put itself back - drops the dead registration and takes a new one. Shubbak picks the shrunk work area back up through the two-second monitor poll it already runs, so the daemon needed no change. The removal before the re-registration looks redundant and is not. Against a shell that really has restarted it addresses an Explorer that never heard of the window and costs nothing; it earns its place when the broadcast arrives without the registration having actually been dropped, which a shell replacement or a tool sending it by hand will do. `ABM_NEW` is refused for a window already on the list, so without it the retry below would fail forever against a reservation that was never lost. Two more things found on the way there. `ABM_NEW`'s result was being ignored and the reservation recorded as made whether or not the shell had accepted it, which turns "the shell was not ready yet" into "there is no bar reservation for this session"; it is now believed, and a refused reservation is retried off the message loop. And the broadcast is allowed through UIPI explicitly, because the README tells people to run the daemon elevated in order to manage elevated windows and the daemon is what starts Taj - so a bar that outranks Explorer, hears nothing from it, and never comes back is a configuration the documentation actively recommends. - **A passing test asserted a rule production had stopped following.** `CommandExecutor` had an `ExecuteAll` that ran a sequence and stopped at the first failure, and `SequenceStopsAtTheFirstFailure` proved it did. Nothing called it. The daemon had used it once, and swapped it for its own loop in order to resolve the foreground window before each command - dropping the stop-on-failure rule in the process, in a commit about something else entirely. The pipe grew its own loop for its own reason. So the rule survived for four weeks as a green test and a comment describing the divergence as deliberate, which is the one shape of dead code that costs more than it saves: everybody reading it believed keybindings behaved a way they had not behaved since August. `ExecuteAll` and its test are gone. The refusal the test also happened to cover - `move --workspace` with nothing focused - is kept as a translation test, which is what it always was. Behaviour is unchanged: keybindings still run every command in a list. It stays that way on purpose now rather than by accident. `WmResult.Succeeded` is documented as "not an error - focusing left from the leftmost window is entirely normal", so aborting a sequence on it would silently truncate every list beginning with a directional command at a screen edge. Anything needing both halves to run should be one command, which is what `move --workspace N --focus` above now is. - **Dalil's float/tile row could send the verb that was already true, and do nothing.** `Ctrl+Shift+F` floated a window, and pressing it again to tile did nothing at all while Enter on the same row worked. The row chose between `float` and `tile` from the window's state, and that state is a snapshot: the host seeds a reopened palette from its cached read before the fresh one lands and refuses to refresh at all while the palette is closed, a drill-in frame is frozen when it is pushed and deliberately ignores refreshes, and the action itself is resolved from a window captured when the row was built. Any of those can describe a floating window as tiled - and `float` on something already floating returns from `SetWindowStateCore` without an event or an error, so the key looked dead. Both wordings now send `toggle-floating`, exactly as Minimise and Make sticky beside them have always sent one command each and varied only the label. A toggle cannot be stale, and the help screen has always described the chord as one. - **Clicking another window broke a full-screen video, and leaving full-screen left the window in the wrong place.** An application that goes full-screen by itself - a browser playing a video, a game in borderless mode - resizes its own window and tells nobody: the only event that reports it is `EVENT_OBJECT_LOCATIONCHANGE`, which Shubbak deliberately does not subscribe to. So the tree still said *tiling*, and since a focus change re-runs the layout, the first click elsewhere dragged the browser back into its tile while the browser still believed it was full-screen - Taj reappearing over a window that was now neither. Leaving full-screen was worse: the application restored its own geometry, the committer skipped the window as already where it was put, and it stayed wrong until something changed its target rectangle - which is why moving *another* window onto the workspace healed it. Shubbak now notices both by looking, at the top of every layout pass and on the loop's existing idle tick, and gives such a window the whole monitor without taking its tile away, so nothing else on the workspace moves while the video plays. - **`Unmaximise` put the window a bar's height too low.** `WINDOWPLACEMENT` is expressed in workspace coordinates, not screen ones, and `rcNormalPosition` was being handed a screen rectangle - the documented mistake whose documented symptom is a window that creeps down the display. Measured with Taj on the top edge, a window asked to restore to `y=400` arrived at `y=434`. The damage was bounded, because the `SetWindowPos` that follows corrected both the position and the stored rectangle, so what was left was a single frame of the window drawn too low - which is precisely the visible jump this call carries a rectangle in order to avoid. Invisible on a desktop with nothing docked at the top or the left, which is why it survived. - **Taj registered an appbar callback and never listened to it.** The shell had been telling the bar that the taskbar had moved, been resized or been hidden (`ABN_POSCHANGED`), and that a full-screen application had opened or closed (`ABN_FULLSCREENAPP`), into a window procedure with no case for either. The bar now re-asserts its reservation on the first, so the work area Shubbak tiles into cannot drift away from where the bar actually is, and drops to the bottom of the z-order for the second, as the taskbar does. - **A config that would not parse silently replaced your bar and your palette with stock ones.** On reload, `TajConfigLoader` answers an unparseable file with `CreateDefault()` and Dalil's loader answered with plain defaults — correct at startup, where there is nothing to keep, and wrong on a reload, where there is. A stray brace mid-edit therefore swapped a carefully built bar for the generic one and reset the palette's colours, size, prefixes and actions, with nothing anywhere to connect the change to the keystroke that caused it. Both now keep what they are running, which is what the window manager has always done with the rest of the same file and for the reason it gives: *a typo must not leave a running desktop with no keybindings*. - **Config diagnostics went to a console that does not exist.** All three processes wrote them to `Console.Error`, and none of the three asks for a console on the path that matters — `shubbak-wm` only with `--foreground`, Taj and Dalil only for `--help` and `--version`. Started at logon, or from `startup-command`, every one of them formatted the line, the column, the caret and the hint and dropped the lot on the floor. So the headline promise — *"instead of failing silently: you press the key, nothing happens, and you go hunting"* — was true only of `shubbak check-config`, which you have to decide to run. They now go to each process's own log as well, through one shared helper so the three cannot drift apart again, which is exactly how this arose: the bar's reporting was written, the daemon's was written, the palette's never was, and nobody noticed that neither of the first two reached a log. - **Taj kept no log unless the config asked for one**, so a file that could not be parsed — which yields defaults, and a default with no log path — left the bar with nowhere at all to say why. That is the one case where being able to say anything matters, and it was the one case with no log. It now defaults beside the window manager's, as Dalil always has; the asymmetry was not a decision. #### Added: the configuration indicators - **`{{ config }}` on the bar.** Empty and invisible while the settings are readable, like `paused` and `suspended`; when it appears, the bar is running on what it had before rather than on what is in the file. Clickable, so it reloads. Each process reports only the part it reads, which keeps the decoupling: the daemon does not need to know what a bar profile is to say that one is wrong. - **`>config` in the palette**, listing what is wrong with the palette's own section — severity and code as badges so `error` narrows the list, the hint on the row rather than a level down, and `Ctrl+C` yielding a `path:line:col` an editor can jump to. The row is absent when there is nothing wrong, because a row that promises problems and lists none teaches you to ignore it. ### Validation of the whole file #### Added - **`shubbak check-config` validates the `dalil` section.** It never had. The section name was on the window manager's allow-list and its contents were on nobody's, so `dalil { with-icons #true }` was accepted in silence and did nothing for ever — in a project whose first selling point is a config file that tells you when you have made a mistake. Twelve diagnostics, all with a line, a column and a caret: unknown settings with a suggestion, numbers outside their range saying which one will be used instead, colours that are not colours, unknown placements, prefixes for modes that do not exist, prefixes longer than the one character the palette can match on, a prefix that takes a character another mode was using and leaves it with none, actions with no name, duplicated action names, actions with nothing in them, and any command inside an action that will not parse — reported in the parser's own words, at the line it is written on. - **A workspace bound to a monitor by name is now an error rather than a shrug.** `monitor=` reads an integer, so `monitor="DISPLAY2"` was dropped on the floor and the workspace quietly took the primary. It now says so, and says that monitors are numbered from 0 in the order `shubbak query monitors` lists them. #### Fixed - **A palette action whose name contains a space could not be run.** The command composer emits a row for any term containing a space — that is how a verb and its arguments become something to press Enter on — and when the text does not parse, that row explains why. Both kinds were inserted above the matches, so typing `code lay` put an unrunnable "unknown command 'code'" row on top of the "Code layout" action it was looking for, and Enter landed on the one that does nothing. Rows that can act still go above; rows that only explain now go below, and are still the only row when nothing else matched, which is the case they were written for. ### The palette as a control surface Dalil stops being a viewer that can also send a few commands, and becomes a control surface. Three things drove it: shortcuts that could not be typed on half the world's keyboards, a safety setting whose default made almost every key in the palette inert, and a list of actions that could do less to a window than a keybinding could. #### Added - **`Ctrl+1` … `Ctrl+8` jump straight to a mode**, in the order the hint bar draws them. Prefixes are faster and cannot be typed at all on several layouts — on German and the international layouts `~` is a dead key, so it produces no character until the next keypress and the mode never changes — and Tab was seven presses from one end of the ring to the other. A digit is one keystroke and is in the same place on every keyboard in the world. - **Prefixes are configurable**, under `dalil { prefixes { … } }`. The defaults are unchanged, so nobody who was happy has to do anything; what changed is that being unhappy is now fixable. An empty string gives a prefix up without losing the mode, which Tab and the jump key still reach. - **Marking, and acting on several windows at once.** `Ctrl+Space` marks a window; `Ctrl+Enter` then offers the set — move them all to one workspace, float them, tile them, minimise them, close them. This is the thing a palette is genuinely for: moving six windows by keyboard is six rounds of find-it, focus-it, move-it, with the focus landing somewhere different after each one. It needs nothing new from the window manager, because the pipe has always accepted a newline-separated sequence. - **"Write a rule for it"**, on every window row and at the top of every report. It composes the KDL that would match that window — class and process live, the executable's path commented out beside them, the title commented out under that — ready to read and paste. The `do { }` block is deliberately left empty: the same window one person wants floated is one somebody else wants ignored, and a generated rule that quietly did the wrong thing would be worse than none, because it would look right. This was the step the flagship feature always left as an exercise. - **"Move it to…"**, which did not exist. The palette could bring a window *here* and could tag it onto a workspace, and could not send it to one — despite `move --workspace` being a verb the window manager has always accepted. Tagging was not a substitute: a tag is a membership that makes the window follow you about. - **Named command sequences**, under `dalil { action "…" { … } }`. Keybindings are a scarce resource — there are only so many chords a person can hold, so anything done twice a week never gets bound and is then done by hand for ever. A palette row costs nothing to have and nothing to remember. They are validated against the real command parser at load time, so a mistake is reported on the row rather than swallowed. - **`diagnose` from the palette.** The method has existed on the pipe since the daemon did and nothing but a shell had ever called it, which is exactly backwards: the report is wanted at the moment something has gone wrong on somebody's desktop, which is the moment they are looking at their desktop. - **Application icons on window rows**, and a caret in the search box with `Left`/`Right`/`Home`/`End`/`Delete`. Commands mode is a text field somebody is composing in rather than a filter, and a typo in the middle of `resize --width +5%` used to cost the rest of the line. - **A first row that answers the question before it is asked.** If the window you were just in is not being managed, the window list says so and offers the reason for one Enter. Only while nothing has been typed. - **The window manager's state, said out loud.** The search box already reported paused tiling and a swallowing binding mode; it now also reports a suspended manager and one that cannot be reached at all. All four look exactly like a crash from the outside, and a dead daemon and a slow one used to produce an identical empty list with identical, confidently wrong, advice. #### Changed - **`action-guard` became `confirm-destructive`.** The old setting turned every direct chord off at once, and its default left every chord in the palette inert except the one that took no action at all — while the action list went on printing those chords as badges beside the rows they belonged to. So the keys were advertised in the one place they were redundant and refused in the only place they would have saved anything. Now the two actions that cannot be undone ask first, by whichever route they were reached, and the eight that can just happen. Closing a window is stricter than it was; floating one is eight keystrokes cheaper. The old name is still read and still means "ask first", so no configuration breaks. - **Tab walks a ring ordered by how often a mode is wanted**, and help is not in it. It used to walk the declaration order of a C# enum, which put monitors between scratchpad and inspect for no reason anybody chose and made help a stop on the way to somewhere else. Help has a prefix, a jump key and an Escape; it does not also need to be in everybody's way. - **The window list is ranked by proximity below the eight most recent.** The list is used for two things that want opposite orderings — switching between the few windows you have been using wants recency absolutely, and finding one you have lost is a search where recency means nothing, because it has not been focused. So the top is left exactly as it was and only the tail is regrouped: same workspace, then same display, then anywhere. - **A short list is drawn in a short window.** Two matches used to be two rows of text above ten rows of empty background. - **Layout rows say what a layout does.** All eleven had "layout" in the dim column, next to a list whose heading already said it — a wasted column in the one mode where the row's own name is jargon. - **Building the window list is 10× faster and allocates 18× less.** Every window row carried a dozen action records, two workspace-sized pickers and a composed rule, built on every refresh to answer a question about exactly one of them: measured on a desktop of 250 windows and 19 workspaces, 1.1 ms and 3.4 MiB per refresh, now 0.1 ms and 183 KiB. The list is only ever read for the selected row, so nothing was traded for it — it was work with no reader. Keystroke latency is unchanged at 0.07 ms. #### Fixed - **`Ctrl+U` and `Ctrl+Backspace` no longer eject you from the mode.** Clearing the query drops the prefix, which silently moves the palette back to the window list — so a key documented as "clear what you typed" also changed what Enter was going to do, and the user had asked for neither. `Ctrl+Backspace` did the same on any single-word term. - **Choosing a monitor with no workspace on it no longer types its name into the command box.** Completing was the fall-through for every command-less row in every mode but help, so `\\.\DISPLAY2` was offered as a verb somebody had started typing. - **Badges no longer drop the ones that matter.** They were drawn from the end of the list backwards and the loop gave up when it ran out of width, which meant a window that was unmanaged, minimised, floating, sticky, tagged onto three workspaces and elevated showed the last three and silently omitted the first three — the only ones that explained why it was not where it had been left. An "also on" badge is now counted rather than listed past a couple of names, so a window tagged onto nineteen workspaces does not lose its own title. - **The selection survives a refresh.** It was preserved by reference identity alone, and a refresh rebuilds every entry from the wire — so the selection went back to the top on every window event, which on a busy desktop is several times a second. - **`Alt+Enter` works.** It has been printed as a badge on "Bring it here" since that action was written and was in no lookup table, so pressing it did nothing. - **`?` lists the keys that exist.** `Ctrl+Shift+C` was implemented and documented in the README and the changelog and missing from the help; so were all five action chords, every one of which is printed as a badge in the list it belongs to. The comment above that list has always claimed a test held it to the implementation. There now is one. - **Rows matched on their application are highlighted.** Finding a window by its process when the title says nothing about it — "Untitled document" — is most of what the dim half of a row is for, and such a row appeared with nothing underlined anywhere, which reads as the palette having matched it by accident. - **A palette that is on screen and cannot be reached is put away.** A window that never became active is never told it has been deactivated, so close-on-blur cannot dismiss it and Escape never arrives. `PaletteWindow.IsStranded` was written for exactly this, with a careful explanation of why it mattered, and was referenced from nowhere at all. - **Dalil survives the window manager shutting down.** It stopped with it, which is the wrong half of the relationship — it reconnects when the daemon comes back, so a restarted window manager had no palette until somebody noticed. - **A query longer than 64 characters no longer risks a crash.** The matcher reports how many characters matched and writes a position only while the caller's span has room, so slicing by the count read past the end. - **Copying a window row takes the class and process too**, which are the attributes somebody is copying it for. The title alone is the one guaranteed to be the wrong thing to match on. - **`AltGr` still types.** Suppressing characters chorded with Ctrl would have broken `@`, `#`, `[` and `{` on exactly the European layouts this release is partly for, since AltGr *is* Ctrl+Alt. - **`toggle-managed` is no longer marked destructive.** It is a toggle; pressing it twice leaves the desktop exactly as it was found. ### Inspection from the palette Inspection, which was the best thing the command line could do and the hardest thing in the palette to find. `shubbak inspect` has always been able to say why a window is not being tiled. Dalil has been able to ask for the same report since it existed — from the bottom of an action list, reached by an undocumented key, under a name that did not contain the word "inspect". This release makes it findable, makes it readable, and stops the palette recovering the report by taking the printed text apart. #### Added - **`Ctrl+Shift+I` inspects the selected window**, from anywhere in the palette. It is the one action the `action-guard` setting does not hold back, and the exemption is principled rather than convenient: the guard exists so an action cannot be taken by accident, and inspecting takes no action. It is the only entry in the list that runs no command at all, which a test holds true. - **An inspect mode, on the `!` prefix.** Every window Shubbak is *not* managing, each saying why on the row itself, ranked so the ones you can do something about — excluded by a rule, not adopted yet — come before the ones that are facts about Win32. This is the palette's answer to `shubbak inspect --all`, which until now was reachable only from a shell. It deliberately ignores `show-unmanaged`. That setting keeps unmanaged windows out of the ordinary list, which is reasonable and would leave this mode permanently empty. - **The reason a window is unmanaged now appears in the window list**, in the dim text where the workspace would be for a managed window. The window manager has always sent this and the palette has always discarded it, so the list could say a window was `unmanaged` and never say why. It is a new short form of the verdict rather than the existing sentence. The long ones run past 150 characters and end with the part that says what to do about it, so a clipped row showed the half that was no use. - **Any report line can be opened in full**, with Enter, and left with Escape or Backspace. A row is one clipped line, and the values worth opening a report for — a path, a regular expression, the sentence about elevation — are the long ones. Wrapping is done by breaking the value across ordinary rows rather than by teaching the palette to wrap. Variable row heights would have meant a measuring layout pass and a window that resizes underneath the selection; this reuses the frame stack that already exists for action lists. - **`Ctrl+C` copies the selected line, `Ctrl+Shift+C` copies everything on screen.** The most useful thing to do with an explanation of why a window will not tile is to paste it into an issue, and until now the only way to get one out of the palette was to read it off the screen and retype it. Rows are copied whole rather than as drawn: a path with an ellipsis in the middle of it is not a path. - **Backspace goes back** when there is nothing left to delete and a list is open. Escape already did. Backspace did nothing at all, which is the least useful of the three available behaviours. - `Ctrl+Enter`, `Ctrl+Shift+I` and `Ctrl+C` are now listed in the palette's own help. The first two already worked and were written down nowhere, so the one page somebody opens to find a key was the one page that did not mention them. #### Changed - **The `inspect` IPC method returns a structured `WindowReport` rather than the text of one**, and the **IPC protocol version is now 2**. The report was built as printed columns in the daemon, and the palette — the other client — split that text back apart at the padding to find the labels. The daemon's choice of whitespace had quietly become an interface for a different process, with nothing anywhere testing it: widening a column would have silently stopped the palette's labels being labels. The fields are the contract now, and the printed layout is decided in exactly one place, next to the code that prints it. **The command line's output is unchanged**, deliberately — people have it pasted in issues and sitting in scrollback. The version rises because this is a payload whose meaning no longer matches its name, which is the documented trigger for raising it. Since the version is part of the pipe name, a `shubbak` and a `shubbak-wm` from either side of this change do not find each other at all, rather than one showing the other's JSON verbatim. - **`shubbak inspect` no longer prints the same seven fields twice.** With a window manager running it printed a local report and then the daemon's, which repeated the handle, title, class, process, path, rect and verdict. There is one report builder and one formatter now, and the local path fills in what it can. - The palette's hint bar says what Enter will actually do to the selected row — `inspect`, `open`, `read it`, `do it` — instead of always saying "do it". Every overlay advertised "do it", including a report whose rows all did nothing, so the one list where Enter was inert was also the one insisting it was not. - The action is called **"Inspect this window"** rather than "Explain this window". The old name described it better and was findable only by somebody who had already found it; the description keeps the old wording, and descriptions are searched too. #### Fixed - **Windows that reopen maximised were tiled while Windows still had them flagged maximised.** Store applications — Calculator and Settings among them — remember that they were maximised and come back that way. Shubbak adopted one as an ordinary tiling window, handed it half the screen, and never cleared `WS_MAXIMIZE`. The compositor draws a maximised window on the assumption that it fills the monitor: the shadow is suppressed and part of the frame is deliberately put off the top of the screen. At half the screen that frame is back on screen as a black strip along the top, and the focus border is drawn around a shape that is not the window — which is how it was reported, as UWP applications having "a strange border and a thin black row on the top" with the border invisible on them. `WindowFilter.InitialStateFor` claimed in its own comment that a window "already minimised or maximised must keep that state". Only the minimised half was ever written. `Win32Window.IsMaximised` existed and was called by nothing, and `WindowState.Maximised` existed and was assigned by nothing. The flag is now cleared before the window is placed, in the committer rather than at adoption. Adoption is only one way in: Win+Up, a double-clicked title bar and an application maximising itself all set it later, by which time the drift watch has expired and `EVENT_OBJECT_LOCATIONCHANGE` is deliberately not subscribed. The committer is the one place every rectangle passes through, and the check runs only for windows actually being moved. Clearing it goes through `SetWindowPlacement`, which carries the destination with it. `SW_RESTORE` alone returns the window to whatever it occupied before it was maximised, a visible jump to a stale position immediately before the layout corrects it. It is also synchronous, and that matters: the committer places windows with a *sending* `SetWindowPos`, and a send overtakes anything merely posted — so the asynchronous form would have arrived after the placement and undone it. A window that is not answering gets the asynchronous form anyway, rather than blocking a layout pass on one stuck application. - **The action chords did nothing, anywhere, with the shipped defaults.** Every row in the action list carries the key that acts on it as a badge — `Ctrl+Shift+S` beside "Make sticky", `Ctrl+Shift+W` beside "Close it" — and pressing any of them did nothing at all. Two guards met. Chords were refused outright whenever a list was open, which is the only place they are written down; and from the main list they were refused by `action-guard`, which is on by default. So the one place a user could learn a chord was the one place it could not work, and the place it could work was the one place nothing said it existed. A chord inside the action list now always acts. The guard is not weakened by that: it exists to stop an action being taken by accident from a list where the keyboard is busy searching, and by the time somebody has pressed Ctrl+Enter and is reading a list of verbs, pressing the key printed on one of them is no more accidental than pressing Enter on it. From the main list the guard applies exactly as before. The chord also survives becoming a row now. It used to reach the list only as the badge — a caption naming a key, on a row that could not be found by that key. - **A mode prefix only ever worked from the window list.** In any other mode the query already began with one, so typing `!` in the command list produced `>!` — still the command list, now searching for an exclamation mark. Only the first character decides the mode, and it was never replaced. Every mode but the default was a one-way door, escapable only with Tab or Backspace. A prefix typed while there is nothing to search now replaces the mode. Once there is a search term it stays literal, because typing `#` after `>foo` is somebody spelling a query rather than changing their mind. This was not new. It applied to all six prefixes for as long as they have existed; adding a seventh is what made somebody try to switch between two of them. - **The hint bar dropped whichever mode came last.** A hint that does not fit is not drawn, and the modes are drawn in the order they are declared — so adding `!` pushed it past the right-hand edge of a 720-pixel palette and it appeared nowhere at all. The bar is now tried at four levels of detail and drawn at the fullest that fits, rather than budgeting for a particular width. What is given up, in order: the word "modes" beside Tab, which explains a key that explains itself; the advertisement for Ctrl+Enter, which is also listed under `?`; and only then the mode names, all together — a bar naming three modes and showing four bare caps would read as the names belonging to the wrong caps. Nothing in it knows how wide Segoe UI is at a given scale, so a wider window, a larger font or another mode all settle at the right level on their own. - **The bar and the palette could each run twice, silently.** Only the window manager had a single-instance guard; `taj` and `dalil` had none, and reached the doubled state by an entirely ordinary route. Both are designed to survive the window manager restarting — they reconnect inside `window-manager-timeout` — and the restarted window manager then runs its startup commands, one of which starts each of them. Two bars is not merely untidy: each reserves its strip through the shell's appbar API, so the work area is taken twice and every tiled window is laid out into a desktop shorter than it should be, which reads as a gaps setting gone wrong. Two palettes both answer the same signal, so one keypress raises two windows, stacked and both topmost, and Escape dismisses one to reveal the other. Both now refuse to start a second copy and say so. An uncertain answer starts anyway, which is the opposite of what the window manager does with the same uncertainty: two bars are visibly wrong and easily undone, whereas no bar at all because a mutex could not be opened is worse than the thing being guarded against. - **Raising the protocol version opened the hole the instance mutex exists to close.** The window manager's mutex was the pipe name with `Local\` in front, and the pipe name carries the protocol version — so a daemon on the old version held a different name from one on the new, and both could run. That is precisely the pairing somebody working on Shubbak produces all day: an installed copy running while a build from source is started. Instance names are no longer versioned. The two questions look alike and are not: a pipe asks whether two builds can understand each other, and a mutex asks whether one is already running — and two bars reserve the same strip of screen whether or not they speak the same protocol. - **`shubbak dalil-exit`**, alongside the existing `taj-exit` and sharing its implementation. Both matter more now that each program refuses to start twice: without a way to stop the one that is running, a wedged palette could only be cleared through Task Manager, which is a worse position than the double-start the guard prevents. - **The icons are visible somewhere other than the taskbar.** Four were drawn for the executables and then only ever seen by Windows: the readme showed none of them, and an ICO is not something GitHub renders. `tools/make-icons.ps1` now writes the 256-pixel frame of each as a PNG into `docs/assets` alongside the ICO it already produced — from the same frames, so the picture in the readme cannot drift from the one in the taskbar. The readme leads with Shubbak's, and Taj and Dalil carry theirs beside their sections. The release zip gains `docs/assets` for the same reason it already carries the readme: the links are relative so that GitHub resolves them, and without the images the copy in the zip would greet a first-time reader with three broken pictures. The executables stay at the root, which is the part winget and Scoop name. The same script draws `docs/assets/social-card.png`, the 1280×640 picture GitHub shows wherever the repository is linked and a URL unfurls. It is composed from the same two functions as the icon, so the tile on the card cannot drift from the tile in the taskbar, and it is checked against GitHub's recommended 40pt border rather than merely laid out inside it — a caption that fits at full size and is cropped away in a Slack unfurl is not a failure anybody would see by opening the file. It has to be uploaded by hand under Settings → Social preview. #### Internal - **A test project for the palette host.** `Dalil.Core` has been tested since it existed; the executable had never been, because the decisions lived inside `PaletteWindow` and that cannot be constructed without a real window, a message loop and a device context. What a keystroke means is decided in a new `PaletteInput` now — which chord a key spells, whether the guard holds it back, what Enter does to a row, and what a copy puts on the clipboard. All four are pure functions of the row and the state it is chosen in, and all four are covered. It also simplified the chord path: inspecting is found by the same lookup as every other chord rather than by a branch of its own, because the action now carries the chord like the rest of them. - **The single-instance mechanics are shared.** The mutex handling — including treating an abandoned mutex as free, so a process that was killed does not lock the user out until they reboot — now lives in `Shubbak.Native.SingleInstanceLock` and is used by all three programs. What stays in the window manager is the part specific to it: standing an incumbent down over IPC for `--replace`, and refusing rather than carrying on when the answer cannot be had. ## [0.9.0] - 2026-08-27 Tagged, built, and never published: the draft release was discarded while the work below was finished, so nobody installed this version. The entry stays because the tag exists and the changes did happen. The first release that shipped is the one above. It was meant as the first public release: the point at which Shubbak can be installed rather than built. Most of what it does predates this entry — tiling, workspaces, the bar, the palette. What is new is everything needed to hand it to somebody else: a release zip and the two package managers that serve it, autostart, a starter config, icons. And alongside that, the things a program which owns your keyboard has to offer before it can be trusted with it — a way to make it let go, a way to see at a glance that it has, and a refusal to be running twice. ### Added - **`wm-suspend`, `wm-resume`, `wm-toggle-suspend`.** Releases the low-level keyboard hook and the window event hooks, and leaves every window exactly where it is. This is for playing a game, and it is not the same as `wm-toggle-pause`. Pausing stops Shubbak rearranging the desktop but **keeps the keyboard hook**, so every bound chord is still swallowed and never reaches the focused application — deliberately, because the command that resumes is a keybinding and a pause that cannot be undone from the keyboard is a trap. That is the wrong property for a game: a chord Shubbak swallows is an input the game never sees, which matters far more than the microsecond the hook costs (ADR 0001 measured p99.9 at 0.8–1.0 µs). The only way to get this before was to exit the window manager entirely, which un-conceals every window on every workspace on the way out and costs a full restart to undo — measured at over two seconds, plus re-adopting every window, plus the bar and the palette restarting with it. Suspending costs none of that: the tree stays in memory and nothing on screen moves in either direction. While suspended the periodic work stops too — no focus-border re-assertion five times a second, no monitor polling — and the loop idles instead of running at frame pace. Resuming uses **the same key that suspended**, which works because `RegisterHotKey` is not a hook: the system matches that one chord itself and posts a single message. Nothing of Shubbak's runs for any other keystroke. `shubbak wm-resume` works too, and is the way back if another program already owns the chord. `shubbak status` now distinguishes `running`, `running, paused` and `running, suspended`, and `shubbak diagnose` reports whether each hook is actually installed — because "is it really out of the way" deserves an answer rather than trust. - **`wm-toggle-pause` is unchanged.** Both exist because they are genuinely different. - **A system tray icon.** Right-click or left-click it for suspend/resume, stop arranging windows, reload configuration, open the configuration folder, and exit. The labels describe the current state rather than being fixed, because the difference between "Suspend" and "Resume" is why anyone opens it. It matters most in the state where the keyboard has been given away: suspending is undoable from here even if the resume chord is already owned by another program. Two details worth recording, because both would be bugs if got wrong. The window is **message-only** — parented to `HWND_MESSAGE`, so `EnumWindows` never returns it. The program enumerating windows and deciding which to tile is this one, and a findable tray window would be a window manager arranging its own plumbing; there is a test asserting it stays invisible to Shubbak's own enumerator. And it lives on the daemon thread, never the keyboard hook's: `TrackPopupMenu` runs a modal loop while the menu is open, which on the hook thread would put every keystroke on the machine behind an open menu against a 300 ms deadline. The icon is taken from the executable itself, so the tray matches Alt-Tab and the taskbar rather than being a second image to keep in step. - **Taj says when Shubbak has stopped.** New template values `status`, `suspended` and `paused`, rendered as pills that hide themselves when there is nothing to report. Both states change what Shubbak does without changing anything on screen, so neither was discoverable by looking. Suspended is the one that matters: it is indistinguishable from a crash — windows stay where they are and no key does anything — so somebody who suspended it and forgot has no way to tell the difference without trying a command and reasoning about the answer. `status` is the combined value for a bar with room for one pill, and suspended wins when both hold. `suspended` and `paused` are separate so a config can show two and give each the click that undoes it — a pill saying "suspended" that resumes when clicked is a way back that does not need the keyboard, which is the one thing suspending took away. - **`shubbak autostart enable | disable | status`.** Registers the window manager to run at logon under `HKCU\...\CurrentVersion\Run`. There was previously no way for Shubbak to start itself: `startup-command` launches other programs once the daemon is already up, which covers the bar and the palette but not the thing running them. `status` reports two failures that were otherwise silent — a registration pointing at binaries that have since been deleted, and one pointing at a *different* copy than the one you are running, which is what "I updated it but the old version keeps starting" actually is. - **`shubbak config init`.** Writes a short starter config — five workspaces and the bindings to drive them — to `$XDG_CONFIG_HOME/shubbak/shubbak.kdl` or `%USERPROFILE%\.config\shubbak\shubbak.kdl`. It refuses to overwrite an existing file without `--force`. This command was already being recommended before it existed: the loader answers a missing config with *"hint: Run 'shubbak config init' to write a starter config"*, and that instruction fell through the CLI's dispatch to the daemon, which replied "no window manager is running". The first thing a new install said to a new user was an instruction that failed. It writes a short file rather than a copy of `shubbak.example.kdl`, which is 600 lines and exists to explain every setting — the right thing to read, the wrong thing to inherit. - **`--version` on all four executables.** It previously did not exist. On the CLI it fell through to the daemon as a window manager command, so asking a stopped Shubbak for its version was answered with "no window manager is running". - **`--foreground` on `shubbak-wm`**, for running it attached to a console. See the subsystem change below. - **`--help` on `dalil`**, which had none, and **`--quiet` on `taj` and `dalil`**, which only the daemon had. - **Application icons** for all four binaries. They had none, so Alt-Tab, the taskbar and Task Manager's Startup tab all showed the generic placeholder. Generated by `tools/make-icons.ps1` rather than committed as opaque files, so a change to them is a diff somebody can read. - **A release workflow.** Tagging `v*` publishes a flat zip of the four NativeAOT binaries with a SHA256, plus a separate symbols archive. ### Changed - **`shubbak-wm` is now a GUI-subsystem binary.** This is the one change with a visible consequence. The subsystem is a field in the PE header that the loader reads before any of our code runs. A console-subsystem process started by something without a console of its own — Explorer, a shortcut, Task Scheduler, the `Run` key — has one allocated for it, and it stays for the life of the process. Autostart would therefore have left a black window on the desktop at every logon, with no runtime flag able to suppress it. What this costs, and how it is paid: - Run it in a terminal and it now attaches to that terminal only when asked, with `--foreground`. The shell does not wait for a GUI-subsystem process, so output arrives underneath your next prompt. Redirect if that matters. - **Startup failures still report.** A daemon that cannot load its config and says nothing would be indistinguishable from one that was never launched, so the failure path takes a console of its own and holds it open long enough to read. - Console logging now follows `--foreground` instead of defaulting on. The alternative was launching the daemon at logon through a hidden PowerShell, which flashes on many machines and produces exactly the process tree — a shell spawning a keyboard-hooking binary at logon — that an antivirus heuristic is built to distrust. - **`taj` and `dalil` no longer format log entries they cannot write.** Both are GUI-subsystem binaries that had console logging on by default, so every entry was built and then discarded. Console output now follows whether output actually leads anywhere. - **`shubbak diagnose` reports `0.9.0` rather than `0.9.0.0`.** The four-part form is what `AssemblyName.Version` gives; comparing it against a `v0.9.0` tag looks like a mismatch that is not one. - **`shubbak-wm --check-config` now points at `shubbak check-config`**, which validates the bar's section of the same file as well and is the fuller check. ### Fixed - **Two window managers could run at once, silently.** There was no single-instance guard of any kind, and the named pipe could not serve as one: it is created with `MaxAllowedServerInstances`, which is precisely the flag that lets any number of processes host the same name. So a second `shubbak-wm` started perfectly happily, and then the two fought — two keyboard hooks, so every binding ran twice; two layout passes issuing contradictory `DeferWindowPos` batches; a CLI reaching whichever accept loop won the race, so consecutive commands could land in different processes; and on exit, one daemon un-concealing windows the other still had recorded as concealed. Nothing reported any of it. A second launch is now refused with a message naming the running process. `--replace` asks the running one to stand down over IPC — so it saves its session and restores its windows rather than being terminated — and waits for it to let go before starting. An abandoned mutex, left by a daemon that was killed rather than asked to exit, counts as free rather than as someone else running. - **A window that moved itself stayed moved.** Reopening Firefox put it on the wrong monitor, on top of the window already tiled there, and it stayed until `wm-redraw` was pressed. Applications reposition their own windows — a browser restoring the geometry it remembered from last time does it a moment after its window appears, which is after Shubbak has placed it. Windows announces that only through `EVENT_OBJECT_LOCATIONCHANGE`, which Shubbak does not subscribe to and still does not: the callbacks arrive on the message queue the pump waits on, so a single dragged window used to produce 122 wake-ups a second and pace the animation loop against its own output. What made this *stick* rather than correct itself was the committer's skip check. It asks "is this window already where I last told it to be", judged on the target alone — so once a window had wandered, every later pass skipped it, because the target had not changed and there was seemingly nothing to do. It now also asks whether the window is still there, but only for windows it was about to skip, and only coarsely: a different monitor, or a long way from its tile. An exact comparison was tried once before and reverted, because a terminal snapping to whole character cells never lands precisely where it was put and so was re-placed on every layout — which, since focus changes run a layout, was a twitch on every focus change. Newly adopted windows are additionally looked at twice, about 300 ms and 900 ms after being placed, which catches the displacement without waiting for something else to trigger a layout. Twice and then never again: an unbounded watch is how this becomes a window manager arguing with an application several times a second for as long as both are running. Worth recording, since it was checked: **neither GlazeWM nor komorebi handles this either.** Both subscribe to `EVENT_OBJECT_LOCATIONCHANGE` and both discard it for tiling windows — GlazeWM falls through to a bare `_ => {}`, komorebi forwards it only to its border overlay. GlazeWM's version of the bug is transient because its commit path has no skip check, so the next redraw of that container corrects it; komorebi's is masked for this specific case by an allowlist that turns a title change into a re-tile, and names Firefox as the reason it exists. - **The scratchpad was one-way.** Stashing worked; pressing the same key again did nothing at all, silently. Every command that declares `TargetsFocusedWindow` is checked against the foreground window before it runs, and refused if that window is not one Shubbak manages. Stashing the last window on a workspace leaves nothing focused, so the foreground became the desktop — and the next press was refused before `ToggleScratchpad` could run. Summoning needs somewhere to *put* a window, not a window to act on, so an occupied slot is now exempt from that check. The single-window case is the common one, because a scratchpad is what you reach for when you want the screen to yourself — which is exactly the case that could never be undone. - **`scratchpad` could not fail to parse.** Its case in the parser ended in an unconditional `return true`: an unrecognised option was skipped, no positional remained, and the slot silently became `default`. So `scratchpad --hide notes` stashed into `default` and summoned from `default`, appearing to work until somebody used two slots and found one had swallowed the other. `--show`, `--hide`, `--toggle` and friends are now `SHB0312`, and the message says the command is already a toggle rather than only listing what is allowed. A trailing `--name` with nothing after it is `SHB0313` instead of quietly meaning `default`. - **Dalil aimed every action on a stashed window at a window about to vanish.** All of them were built on a `focus-window` prefix, and focusing a cloaked window reveals it without unstashing it, so it concealed itself again at the next layout pass — which reads as the palette having done nothing. `PaletteEntries` documented why the row itself must summon by slot; `PaletteActions` did not follow suit. It now does, so closing, tagging and un-managing a stashed window reach it. "Go to it" becomes "Summon it", and "Bring it here" is dropped as a duplicate of it. - **`wm.suspended` was published but could not be subscribed to by name.** The topic was missing from `IpcProtocol.Topics`, which is what a subscription is checked against — so `shubbak sub wm.suspended` was refused for a topic the daemon was actively publishing. It also carried an empty `{}` payload rather than saying what had changed. There was already a test for exactly this, `EveryPublishedTopicIsDeclared`, and it passed — because it compared the topic list against a **second hand-maintained list**, which had drifted in the same way and for the same reason. It now derives the published topics from the event types themselves, so it cannot drift again. Verified by removing the entry and watching it fail. - **`taj --help` printed nothing at all.** It is a GUI-subsystem binary writing to a console it did not have. Same for every diagnostic it wrote before its window existed. - **The manifest declared version `1.0.0.0`** while the product was `0.9.0`. Both manifests are now checked against `` during the build, so the mismatch is an error rather than something to notice later. ### Verified - **The NativeAOT claim.** All four executables now publish AOT with zero trim or AOT warnings, which had never been tested: the only evidence in the repository was for a spike, and all four defaulted to `PublishAot=false`. CI now publishes them on every push, runs each one, and checks its subsystem — so a release is not the build that discovers a problem. - The `net10.0-windows` target pulls in ~25 MB of CsWinRT projections that nothing uses. AOT removes them entirely; the four binaries total about 19 MB, and the zip is under 9 MB. ### Known limitations - **Not code signed.** SmartScreen will warn on first run. More importantly, `uiAccess` requires an Authenticode signature *and* installation under `%ProgramFiles%`, so this build cannot move windows belonging to elevated processes unless it is itself run elevated. A signed installer is the next release. - **x64 only.** There is no ARM64 configuration yet. [Unreleased]: https://github.com/MoaidHathot/Shubbak/compare/v0.10.0...HEAD [0.10.0]: https://github.com/MoaidHathot/Shubbak/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/MoaidHathot/Shubbak/tree/v0.9.0