Proof Portal
Omarchy
ProbeLabs65 findings · 95 requirementsA proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.
Background reveal animates on only one monitor: the first screen that loads the image starts the shared reveal
Reported by xPsycho999 · filed Oct 2, 2026 · open at source
At a glance
- 1 · What is claimed?
- Only the report text below — no validator has restated it as checkable claims yet.
- 2 · What does it affect?
- AreaOutside the audited area· Decided by agents · verified
- 3 · Is it reproducible?
- No validation has been run yet.
- 4 · What remains unclear?
- Not validated yet: no claims, no reproducer.
- 5 · What happens next?
- Decided by agents and verified by a second, independent agent — see the decision.
Original report
System details
AMD Ryzen 5 3600X, AMD RX 7800 XT, Omarchy 4.0.4 (omarchy 4.0.4-1). Three monitors: 2560x1440 (DP-1), 1920x1080 portrait (DP-3), 1920x1080 (HDMI-A-1).
What's wrong?
On a multi-monitor setup, the background reveal animates on only one monitor. The other monitors show the old image for the full 420 ms. Then they cut to the new image in one step. I measured it with background set (wallpaper change). themeTransition (theme change) uses the same start logic.
Cause
All screens share one reveal animation (revealProgress, revealStartedVersion). But each screen loads its own copy of the incoming image, and each screen has its own maskReady.
- The first screen that has
incomingFrame.status === Image.ReadycallsstartReveal(panel). That setsmaskReadyon this screen only and startsrevealAnimation. - When another screen finishes loading,
maybeStartReveal()seesrevealProgress !== 0and returns.maskReadystaysfalseon that screen. incomingLayer.visibleneedsrevealProgress >= 1 || panel.maskReady. Thus the other screens show the incoming image only when the reveal ends.
A screen joins the reveal only if it finishes loading in the same event loop turn as the first screen. That almost never occurs.
Evidence
I added console.warn lines to a clone of the plugin. One background set call gave these times (ms after the IPC call):
| Screen | incomingFrame ready |
|---|---|
| HDMI-A-1 | 138 |
| DP-3 | 162 |
| DP-1 | 184 |
The stock code starts the reveal at 138 ms. DP-3 and DP-1 are 24 ms and 46 ms late, and they do not animate. Screenshots of all three outputs during the reveal confirm this: only HDMI-A-1 shows the slanted wipe. The other two cut to the new image at about 600 ms.
The current quattro branch has the same logic (maybeStartReveal with revealProgress !== 0, and startReveal(panel)). The decode at screen size (#13408) makes the load faster, but it does not remove the race.
Related: #13125 reports "the new wallpaper appears only when the animation finishes" and suspects revealMask with visible: false. On my machine the mask animates correctly with visible: false on the screen that starts the reveal. On a multi-monitor setup, the race above gives the same symptom. Maybe the two reports have the same cause, but I cannot confirm this for the setup in #13125.
Steps to reproduce
- Use two or more monitors.
- Run
omarchy-shell background set <path-to-other-image>, or change the theme. - Only one monitor shows the reveal. The others cut to the new image after about 420 ms.
Fix
Count the screens that are ready. Start the reveal when all screens are ready, or after a short wait (250 ms, shorter than pendingThemeFallbackTimer, so that the colors still change when the reveal starts). A screen that is ready after the start sets maskReady and joins the running reveal, so it does not cut at the end.
I use this change as a local clone on Omarchy 4.0.4. All three monitors now show the reveal at the same time. This is the same change as a diff against quattro:
--- a/shell/plugins/background/Background.qml
+++ b/shell/plugins/background/Background.qml
@@ -39,6 +39,8 @@
property int backgroundVersion: 0
property int revealStartedVersion: -1
property int pendingThemeVersion: -1
+ // Number of screens that loaded the incoming image for this backgroundVersion.
+ property int readyPanels: 0
property string pendingColorsRaw: ""
property string pendingShellRaw: ""
property real revealProgress: 1
@@ -74,6 +76,8 @@
currentBackground = finalPath
backgroundVersion += 1
revealStartedVersion = -1
+ readyPanels = 0
+ revealWaitTimer.stop()
revealAnimation.stop()
finishingTransition = false
@@ -122,11 +126,26 @@
if (!incomingBackground || revealProgress >= 1) applyPendingTheme()
}
- function startReveal(panel) {
+ // All screens share one reveal animation, but each screen loads its own copy
+ // of the image. Start the reveal when all screens have their image, or when
+ // revealWaitTimer ends. A screen that is late joins the running reveal.
+ function panelReady(panel) {
if (!incomingBackground) return
panel.maskReady = true
if (revealStartedVersion === backgroundVersion) return
+ if (panel.readyVersion !== backgroundVersion) {
+ panel.readyVersion = backgroundVersion
+ readyPanels += 1
+ }
+ if (readyPanels >= Quickshell.screens.length) startReveal()
+ else if (!revealWaitTimer.running) revealWaitTimer.restart()
+ }
+
+ function startReveal() {
+ if (!incomingBackground) return
+ if (revealStartedVersion === backgroundVersion) return
revealStartedVersion = backgroundVersion
+ revealWaitTimer.stop()
applyPendingTheme()
revealAnimation.restart()
}
@@ -245,6 +264,15 @@
onTriggered: root.preparedBackground = ""
}
+ // Maximum wait for slow screens. Keep it shorter than
+ // pendingThemeFallbackTimer, so the colors change when the reveal starts.
+ Timer {
+ id: revealWaitTimer
+ interval: 250
+ repeat: false
+ onTriggered: root.startReveal()
+ }
+
Timer {
id: pendingThemeFallbackTimer
interval: 300
@@ -294,6 +322,7 @@
updatesEnabled: true
property bool maskReady: false
+ property int readyVersion: -1
// Decode the wallpaper at the size this screen can show, not the size
// it was shipped at. With PreserveAspectCrop Qt takes sourceSize as the
@@ -317,12 +346,12 @@
}
function maybeStartReveal() {
- if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
+ if (!root.incomingBackground || root.revealProgress >= 1 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
Qt.callLater(function() {
- if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
+ if (!root.incomingBackground || root.revealProgress >= 1 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
- root.startReveal(panel)
+ root.panelReady(panel)
})
}
View raw text
### System details
AMD Ryzen 5 3600X, AMD RX 7800 XT, Omarchy 4.0.4 (`omarchy 4.0.4-1`). Three monitors: 2560x1440 (DP-1), 1920x1080 portrait (DP-3), 1920x1080 (HDMI-A-1).
### What's wrong?
On a multi-monitor setup, the background reveal animates on only one monitor. The other monitors show the old image for the full 420 ms. Then they cut to the new image in one step. I measured it with `background set` (wallpaper change). `themeTransition` (theme change) uses the same start logic.
### Cause
All screens share one reveal animation (`revealProgress`, `revealStartedVersion`). But each screen loads its own copy of the incoming image, and each screen has its own `maskReady`.
1. The first screen that has `incomingFrame.status === Image.Ready` calls `startReveal(panel)`. That sets `maskReady` on this screen only and starts `revealAnimation`.
2. When another screen finishes loading, `maybeStartReveal()` sees `revealProgress !== 0` and returns. `maskReady` stays `false` on that screen.
3. `incomingLayer.visible` needs `revealProgress >= 1 || panel.maskReady`. Thus the other screens show the incoming image only when the reveal ends.
A screen joins the reveal only if it finishes loading in the same event loop turn as the first screen. That almost never occurs.
### Evidence
I added `console.warn` lines to a clone of the plugin. One `background set` call gave these times (ms after the IPC call):
| Screen | `incomingFrame` ready |
|---|---|
| HDMI-A-1 | 138 |
| DP-3 | 162 |
| DP-1 | 184 |
The stock code starts the reveal at 138 ms. DP-3 and DP-1 are 24 ms and 46 ms late, and they do not animate. Screenshots of all three outputs during the reveal confirm this: only HDMI-A-1 shows the slanted wipe. The other two cut to the new image at about 600 ms.
The current `quattro` branch has the same logic (`maybeStartReveal` with `revealProgress !== 0`, and `startReveal(panel)`). The decode at screen size (#13408) makes the load faster, but it does not remove the race.
Related: #13125 reports "the new wallpaper appears only when the animation finishes" and suspects `revealMask` with `visible: false`. On my machine the mask animates correctly with `visible: false` on the screen that starts the reveal. On a multi-monitor setup, the race above gives the same symptom. Maybe the two reports have the same cause, but I cannot confirm this for the setup in #13125.
### Steps to reproduce
1. Use two or more monitors.
2. Run `omarchy-shell background set <path-to-other-image>`, or change the theme.
3. Only one monitor shows the reveal. The others cut to the new image after about 420 ms.
### Fix
Count the screens that are ready. Start the reveal when all screens are ready, or after a short wait (250 ms, shorter than `pendingThemeFallbackTimer`, so that the colors still change when the reveal starts). A screen that is ready after the start sets `maskReady` and joins the running reveal, so it does not cut at the end.
I use this change as a local clone on Omarchy 4.0.4. All three monitors now show the reveal at the same time. This is the same change as a diff against `quattro`:
```diff
--- a/shell/plugins/background/Background.qml
+++ b/shell/plugins/background/Background.qml
@@ -39,6 +39,8 @@
property int backgroundVersion: 0
property int revealStartedVersion: -1
property int pendingThemeVersion: -1
+ // Number of screens that loaded the incoming image for this backgroundVersion.
+ property int readyPanels: 0
property string pendingColorsRaw: ""
property string pendingShellRaw: ""
property real revealProgress: 1
@@ -74,6 +76,8 @@
currentBackground = finalPath
backgroundVersion += 1
revealStartedVersion = -1
+ readyPanels = 0
+ revealWaitTimer.stop()
revealAnimation.stop()
finishingTransition = false
@@ -122,11 +126,26 @@
if (!incomingBackground || revealProgress >= 1) applyPendingTheme()
}
- function startReveal(panel) {
+ // All screens share one reveal animation, but each screen loads its own copy
+ // of the image. Start the reveal when all screens have their image, or when
+ // revealWaitTimer ends. A screen that is late joins the running reveal.
+ function panelReady(panel) {
if (!incomingBackground) return
panel.maskReady = true
if (revealStartedVersion === backgroundVersion) return
+ if (panel.readyVersion !== backgroundVersion) {
+ panel.readyVersion = backgroundVersion
+ readyPanels += 1
+ }
+ if (readyPanels >= Quickshell.screens.length) startReveal()
+ else if (!revealWaitTimer.running) revealWaitTimer.restart()
+ }
+
+ function startReveal() {
+ if (!incomingBackground) return
+ if (revealStartedVersion === backgroundVersion) return
revealStartedVersion = backgroundVersion
+ revealWaitTimer.stop()
applyPendingTheme()
revealAnimation.restart()
}
@@ -245,6 +264,15 @@
onTriggered: root.preparedBackground = ""
}
+ // Maximum wait for slow screens. Keep it shorter than
+ // pendingThemeFallbackTimer, so the colors change when the reveal starts.
+ Timer {
+ id: revealWaitTimer
+ interval: 250
+ repeat: false
+ onTriggered: root.startReveal()
+ }
+
Timer {
id: pendingThemeFallbackTimer
interval: 300
@@ -294,6 +322,7 @@
updatesEnabled: true
property bool maskReady: false
+ property int readyVersion: -1
// Decode the wallpaper at the size this screen can show, not the size
// it was shipped at. With PreserveAspectCrop Qt takes sourceSize as the
@@ -317,12 +346,12 @@
}
function maybeStartReveal() {
- if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
+ if (!root.incomingBackground || root.revealProgress >= 1 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
Qt.callLater(function() {
- if (!root.incomingBackground || root.revealProgress !== 0 || maskReady) return
+ if (!root.incomingBackground || root.revealProgress >= 1 || maskReady) return
if (incomingFrame.status !== Image.Ready) return
- root.startReveal(panel)
+ root.panelReady(panel)
})
}
```
Validation
Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.
Linked
- Fixed bypr/9312· confirmed
Decision
Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.
- Out of scopeDecided by agents · verified · 2026-10-09 20:43 UTC