1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
|
# AGENTS.md
Guidance for agents working in this repository.
## What this is
Keystone: Quickshell components for a Hyprland desktop. The repo is a hybrid: `desktop/`
is the integrated process, hosting the parts that need to talk to each other,
the drawer, which is the control center, the island, and the assistant
canvas. The other directories stay separate shells, each complete on its own:
any of them runs alone, and running one does not require the others. `desktop/` is itself a host for
modules.
desktop/ the drawer (SUPER alone): user details, sound, mail, VMs, status,
performance, clipboard, updates; and the settings
window for pages set once: wallpaper, theme, icons,
sunset, idle (the former appearance shell), autostart,
the assistant's daemon and presets, qtmaildir, git
island/ top-centre pills on DP-1: clock, volume, tray, layout, privacy,
calendar, OBS
assistant/ the assistant canvas on SUPER+Home, a view over desktop-assistant
window-switcher/ expose: open windows scattered as live previews, on ALT+TAB
or the top-right hot corner of DP-1
keybinds/ live keybind reminder, on SUPER+k
notifications/ balloons for the notification daemon's published files
They are started from `~/.config/hypr/sections/autostart.lua` and keep running
for the whole session.
## Reference: Modesty
The island, drawer and calendar work follows the model of
[Modesty](https://github.com/sandeepbist/Modesty), a Quickshell desktop. A
clone lives at `~/Programming/GIT/GITHUB/Modesty`; `git pull` it before
relying on it. **It is GPLv3 and this repo is GPLv2-only: read it for ideas,
never copy code from it.** The roadmap and the reasons behind it are in
`docs/superpowers/specs/2026-10-07-island-core-design.md`.
## The rule that bites first
**A quickshell config with no visible window exits.** These components are
hidden most of the time, so each holds itself open with a 1x1 transparent
`PanelWindow` with `mask: Region {}`, which is click-through and draws nothing.
Without it the shell loads, logs `Configuration Loaded`, reports no error, and
quits. The symptom is never an error message: it is a keybind that appears to
do nothing, or a panel that never paints. This was removed once during
development because a process check was measuring the wrong thing, and two
components broke in exactly that way. Any new component needs the same window.
## Verifying, and how not to
Two traps cost real time in this repo, both about measurement rather than code.
**A detached `qs` does not survive an agent's tool call.** Starting one with
`&`, `nohup` or `setsid -f` and then checking `pgrep` in a later call reports
`DEAD` regardless of whether the config is sound. That reads exactly like the
no-visible-window exit above and sent a debugging session in the wrong
direction for a dozen calls. Start it so the harness owns the process, and
confirm with the log rather than a later `pgrep`.
**The process is called `qs`, not `quickshell`.** Both binaries ship, but `qs`
is what runs, so `pkill -x quickshell` and `pgrep -x quickshell` match nothing
and silently succeed. During development this meant every "stopped" was a lie
and every restart stacked another instance: 47 accumulated before the user
noticed. It also produced "0 processes" readings that were mistaken twice for
the shell exiting, and sent a debugging session after a bug that was not
there. Use `pkill -x qs` and `pgrep -cx qs`, and check the count after.
**`pkill -f` matches the agent's own shell.** The working directory is in the
command line, so `pkill -f qs` or `pkill -f "foo.qml"` kills the caller and
returns 144. Always `-x`, never `-f`.
For anything visual, ask. Screenshots of a transient OSD are a race, and the
user has the screen.
## Per-component notes
Each directory's README carries its own, and they are worth reading before
changing that component. The ones that generalise:
- **PipeWire nodes report their initial volume before `ready` goes true.** The
`ready` check alone suppresses the startup values. An extra guard on top of
it ate the user's first keypress instead.
- **`PwObjectTracker` is not optional.** Node properties only stay current
while something binds the node; without it the volume reads once and goes
stale.
- **`PwNode.isSink` reads true for a playback stream.** Firefox playing
audio is `isStream` and `isSink`, so `isStream && !isSink` finds no apps.
Match `type === PwNodeType.AudioOutStream`. A node's `properties` are empty
until it is bound, so read `application.name` only under a tracker.
- **playerctld publishes a duplicate of every MPRIS player** under its own bus
name. Filter by `dbusName`, not by identity.
- **Key events reach a focused item, not a window.** Setting
`WlrLayershell.keyboardFocus` is necessary but not sufficient:
`Keys.onEscapePressed` on a `PanelWindow` never fires.
- **A `Row` sizes to its children, not its parent.** Fixed child widths inside
one overflowed the panel and pushed a button past its edge.
- **QML's JS engine has no `String.matchAll`.** It throws, and inside a `try`
that looks like a parser quietly returning nothing. Use an `exec` loop.
- **Assigning `running = true` to a `Process` that is already running does
nothing.** Reusing one `Process` for a sequence of commands needs
`running = false` immediately before each start.
- **`notmuch` fails two different ways and only one is detectable.** A
rejected query prints nothing and exits 1; a query Xapian merely misparses
returns a plausible wrong number and exits 0 (`tag:unread and ((` gave 41).
Validate the output as an integer, which catches the first, where empty
output would otherwise render as an empty inbox. Nothing catches the second,
so keep such queries as fixed strings.
- **notmuch deduplicates by message id, so one message can have several
paths.** A message that arrived at two configured addresses is counted by
both accounts under a `path:` glob, and per-account counts then sum above the
total. The `account-*` tag is a property of the message, so it is singular.
- **Xapian replaces files on commit.** A watch held on a filename inside the
database directory dies with the file; watch the directory for
`close_write,moved_to` instead, and debounce, because one commit touches
several files.
- **An INI section body is not "everything up to the next `[`".** Values can
themselves contain brackets: `qtmaildir.conf` has folders named
`[Gmail]/Bozze`, which ended a section before its `label` and made three of
five accounts silently display their raw key. Walk lines instead. A first fix
using a lazy quantifier and a lookahead dropped every label, which is the
argument for the boring version.
- **libvirt's own memory and disk figures are not what they look like.**
`balloon.current` is memory allocated to the VM and reads full forever;
`block.allocation` is qcow2 growth on the host, not usage inside the guest.
The real numbers come from qemu-guest-agent, and the panel shows a dash
rather than substituting the host-side ones.
- **A managed save makes `virsh start` a restore, and nothing in `domstats`
says so.** A shut-off VM carrying a saved memory image fails to start with
`unable to execute QEMU command 'migrate-incoming'` every time, and the
state reads a plain `shut off`. The shut-off *reason* is no help either: it
reads `failed`, from the failed start, not from the save. `virsh list
--all --managed-save` prints `saved` in the state column but not with
`--name`, which is the form the panel lists with, so detection is `virsh
dominfo <vm>` grepped for `Managed save: yes`, polled per VM the way the
agent rows are. It measured 6ms.
- **Tray menus need `//@ pragma UseQApplication`** at the top of the root QML
file. Without it `SystemTrayItem.display()` only logs a qCritical ("not
started in QApplication mode") and nothing opens. Pragmas are read at launch,
before the first import, so adding or changing one needs a restart, not a hot
reload. `desktop/shell.qml` carries it.
- **A tray menu is a separate popup, and hovering it un-hovers the island.**
The pills lose the pointer, so the hide timer would close the island under
the open menu. `display()` reports no close, so the island holds itself shown
after it until the pointer returns (a ponytail ceiling in `Island.qml`).
- **A mouse button nothing accepts does not reliably fall through to a
catcher underneath.** The drawer's back button (`BTN_SIDE`, `Qt.BackButton`)
never reached a full-surface `MouseArea` below the panel while a page was
open. A `MouseArea` on top with `z: 1` and `acceptedButtons: Qt.BackButton`
works, and left clicks still pass through it to the items below.
- **A `WheelHandler` on an `Image` inside the island's status pill never
fired**; `MouseArea { onWheel }` in the same place works, and `SoundPage`
already used it. Prefer `MouseArea.onWheel`.
- **udt can snap the accent to the same colour as a palette colour used for
meaning.** Measured: accent and green were both `#36d190`. The island's idle
sliver is therefore `Theme.overlay`, never the accent; anything that signals
state by colour must not use the accent as its neutral.
- **`hyprctl switchxkblayout current next|prev` targets the main keyboard**, so
no device lookup is needed. Hyprland's `activelayout` raw event fires on
every switch, so the layout flag is event-driven rather than polled.
- **A bare-modifier release bind works on Hyprland 0.56.2.** `hl.bind("SUPER
+ Super_L", ..., { release = true })` fires on SUPER pressed and released
alone, and is cancelled by any key in between and by `SUPER`+drag and
`SUPER`+scroll; all checked by hand. That is how SUPER alone opens the
drawer.
- **`FileView.setText` creates missing parent directories**, so `Prefs.qml`
has no mkdir step. `Quickshell.stateDir` is a per-shell hashed directory
(`by-shell/<hash>`), not a plain directory, which is why the preferences
path, `$XDG_STATE_HOME/keystone/desktop.json`, is built by hand instead.
- **`FileView.setText("")` on a path that view has not loaded writes no file at
all and reports no error**, with or without `atomicWrites` (measured).
`Prefs.qml` never hits it because JSON is never empty; a note body can be, and
one landed in `db.json` with no file beside it. Content that may be empty goes
through a `Process` running `cat > <file>` with the text on stdin, whose exit
code is the outcome; `notes` writes bodies that way.
- **khal 0.11 has no JSON output**, which is why the calendar reads the vdir
itself through `desktop/island/calendar/calctl` rather than parsing `khal
list -f`. A month list over the 281 live event files measured 0.11s. khal
and cal-notif read the same files and are unaffected by calctl's writes.
- **Recurrence is expanded in naive local time.** A TZID event keeps its
wall-clock time across DST that way, as RFC 5545 asks; `test_calctl.py`
pins a weekly 09:00 on 26 October, the day after the change.
- **Use `/usr/bin/python3` for calctl and its test.** `python3` on PATH is a
pyenv shim without `icalendar`; the system interpreter has it, for
cal-notif.
- **`FileView` reads `/proc` and `/sys` fine, but only on `reload()`.** The
reload is what matters, not `blockLoading`: measured, `reload()` then `text()`
returned the contents from *before* the reload, for a regular file and for
`/proc/stat` alike, and only `waitForJob()` between them returned the new ones
(the docs agree: `reload()` will not block if `blockLoading` is set, only
`blockAllReads` will). `Perf.qml` reloads and reads without it, so a sample
reads the previous interval's numbers; `notes` writes `reload()` +
`waitForJob()`. hwmon numbers move across boots; look chips up by their `name`
file.
- **A hot reload does not resize an existing layer surface.** Raising the
island's `implicitHeight` from 360 to 480 reloaded cleanly while `hyprctl
layers` still reported `h: 360` and the performance panel stayed cut off;
`./reload.sh desktop` applied it. Check the layer size, not the source.
- **A QML method cannot be named `escape`.** It shadows the global JS
function, and the file fails with `Illegal method name` at load, which a
hot reload reports as the whole island being unavailable. The island's
Escape hooks are `back()`.
- **A pill that expands for "any panel" grows for its neighbour's.** The
status pill once expanded on `panel !== ""`, which was right while it
hosted every panel; the calendar lives in the clock pill, and the status
pill grew into an empty box beside it. Each pill names its own panels.
- **Waybar is gone, both bars.** Its DP-3 functions moved to the island. The
mail count is drawer-only: `Accounts.qml` watches the xapian directory
itself, and `waybar-mail.sh` was deleted.
- **Hyprland 0.56.2 evaluates dispatch arguments as Lua.** `dispatch
focuswindow address:0x...` is a syntax error rather than a command, and it
fails silently unless stderr is read. The working form is `dispatch
hl.dsp.focus({ window = "address:0x..." })`. This is why
`~/bin/hypr-windows.sh` is written the way it is, and its syntax was misread
as legacy oddity worth modernising.
- **A focus dispatched while an overlay holds the keyboard is accepted and then
ignored.** A layer surface with `keyboardFocus: Exclusive` grabs the
keyboard, and the compositor will not move window focus out from under that
grab. The dispatch reports `ok`, nothing moves, and no log line says
otherwise. Closing in the same turn does not help either, because a `close()`
that clears a property leaves the surface alive until the frame after. Focus
after the overlay is really gone, which `window-switcher` does with a 60ms
timer.
- **`HyprlandToplevel` has no `focusHistoryID` property.** It reads
`undefined`, so a sort on it compares `NaN` and silently does nothing,
leaving a plausible looking list in arbitrary order. The value is on
`lastIpcObject`.
- **`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints
and that every dispatch requires.
- **`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.**
- **An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**,
not `(0, 0)`. An aspect ratio guard has to test for a positive height: the
obvious rewrites, `!== 0` or a truthiness check, all pass on `-1` and produce
a negative ratio.
- **A `pragma Singleton` in a subdirectory needs no `qmldir`.** A plain
directory import resolves it, the same way quickshell resolves the
`Theme.qml` symlink. Tested while merging the components: the control was a
reference to an undefined type, which warns `ReferenceError: <name> is not
defined`, and the singleton case produced no such warning.
- **`Virsh.sampling` gates the 2s stats poll, not the whole service.** The
lifecycle event stream runs unconditionally, which is what keeps the VM list
and the tile's dots current while the page is closed.
- **`Quickshell.Networking` and `Quickshell.Bluetooth` start empty.** Devices,
the adapter and the wifi radio all read empty or false for roughly two
seconds after launch, then populate. Bindings repaint when they arrive;
nothing may cache the first paint. Both device lists are `ObjectModel`s, so
iterate `.values`, which is a plain array.
- **`NMSettings` is not in QML scope.** `typeof NMSettings` is `undefined`, so
a NetworkManager profile cannot be built from scratch in QML. This is why
the network module cannot join a hidden network, and the same class of
limit blocks enterprise 802.1x.
- **Quickshell has no BlueZ pairing agent and no generic D-Bus module.** A
device needing a passkey or PIN confirmed cannot be paired from QML;
`bluetoothctl` registers its own agent, so pairing is the one shell-out for a
device action in the bluetooth module.
- **`WifiNetwork.signalStrength` is `0..1`, not a percentage.** The known
networks a scan has not refreshed report a cached `1`.
- **Quickshell 0.3.1 has no generic D-Bus module**, only `Quickshell.DBusMenu`.
KDE Connect state therefore comes from `qdbus6` shell-outs, not a binding,
which means there is no push and the drawer polls 5s while it is open. The
poll is gated on the tile's lifetime, since the tile exists exactly while the
drawer panel is loaded.
- **`kdeconnectd` and `kdeconnect-indicator` are separate.** The daemon runs
from a system autostart; the indicator was started from the live Hyprland
config. The phone-to-PC clipboard is the daemon's clipboard plugin
(`KSystemClipboard`), so removing the indicator does not touch it.
- **The KDE Connect battery plugin is not on every device.** `qdbus6` errors on
`.../devices/<id>/battery` for a device without it, so an absent battery is an
empty field, never a zero.
- **`qdbus` resolves a bare `org.kde.kdeconnect.device.<name>` as either a
property or a method**, so `name`, `isReachable` and the `verificationKey()`
method all use the same call shape.
- **A component property named the same as the enclosing object's `id` binds to
itself.** `KdeConnectModule` is `id: mod` and used to inject `KdeConnectTile {
mod: mod }`; in a `Component` scope that resolves to the tile's own unset
property, so `mod` arrived undefined and the page rendered no devices, with
only a runtime `TypeError` in the log and a clean smoke check (the drawer was
closed, so nothing was instantiated). Inject an id under a different name:
`net`, `bt`, `kc`.
- **Neither scan property self-terminates, and closing the drawer does not stop
them.** `BluetoothAdapter.discovering` is plain BlueZ discovery and
`WifiDevice.scannerEnabled` is a repeating scanner; only `discoverable` and
`pairable` have timeout properties, and the scan ones have none. The scan
button was the sole writer of each, and a drawer close destroys the page
without touching them, so a scan started and forgotten kept the radio busy
for the rest of the session. Both pages now carry a `Timer` bound to the scan
property rather than started by the button, so a manual stop cancels the
clock and reopening the page re-arms a fresh 60s.
- **This machine does run a logind.** `elogind` is running, `pam_elogind.so` is
in the PAM stack for `login`, `sddm`, `xdm` and `kde`, and `loginctl
list-sessions` reports a tracked session on seat0. "Slackware has no logind"
is the obvious assumption and it is wrong; it reached a committed spec once.
The real caveat is narrower: `man 8 pam_elogind` says the module does nothing
if the system was not booted with elogind as its init, which here it is not,
so its documented removal of `$XDG_RUNTIME_DIR` at last logout is not
guaranteed for this configuration. A reboot clears it regardless, because
`/run/user/1000` is tmpfs.
- **hypridle honours the Wayland idle-inhibit protocol here.** The compositor
advertises `zwp_idle_inhibit_manager_v1` (version 1), confirmed with
`wayland-info`, and waybar's built-in `idle_inhibitor` already drives it. So
an idle inhibitor is asserted over Wayland, not D-Bus, and needs no extra
service. `hypridle` has `ignore_dbus_inhibit` and `ignore_systemd_inhibit`
knobs; neither is set in the live config.
- **waybar's built-in `idle_inhibitor` cannot indicate state it does not own.**
It holds its own inhibitor object, and its `activated`/`deactivated` classes
are that object's state, with no input path. It cannot be demoted to an
indicator for a mode owned elsewhere, and left running alongside another
inhibitor it asserts a second, independent one, so idle resumes only when
both release. Replace it with a `custom/` module reading the owning
component's state.
- **A `FileView` that writes the file it watches sees its own write.**
`watchChanges` fires `fileChanged` on `setText()` as well as on an external
change, so a handler that writes in response to a change loops. The status
registry compares the reparsed value against the current one and assigns
only on a difference, which makes the self-write a no-op.
- **`IdleInhibitor` needs a non-null `window`.** It has no window of its own
and does nothing without one. A singleton therefore cannot assert an
inhibitor unaided: `shell.qml` hands it the keepalive `PanelWindow`, which
is the one window that exists for the whole session.
- **A layer-surface idle inhibitor is not observable via `hyprctl clients` or
`hyprctl layers`.** `hyprctl clients | grep -ci inhibit` counts the
per-toplevel `inhibitingIdle` field only, and an inhibitor asserted on a
`PanelWindow` layer surface appears in neither listing, so the count does not
move even when the inhibitor is asserted. Check it behaviorally: run a
throwaway `hypridle -c` with a short timeout whose `on-timeout` writes a
marker, confirm the marker does not appear while presentation is on, and
that it appears within a few seconds of turning presentation off.
- **The notification daemon is a separate process that owns the D-Bus name.**
The quickshell side only reads its published files under
`$XDG_RUNTIME_DIR/notifyd/`; the files are the interface, and `notifyctl` is
the one path back. `notifications/` is inert without it: balloons still
draw, but close and actions do nothing.
- **Suppression is in the balloon shell, not the daemon.** `status.dnd`
withholds low and normal balloons and `notifyd/snooze` withholds all, but
the drawer's reserved space lists everything, because a list the user opened
is not an interruption.
- **A notification appears as a balloon or in the drawer's reserved space,
never both.** The drawer writes `notifyd/drawer` and the balloon shell reads
it; `desktop/NotificationList.qml` is the space.
- **The old waybar notification module shelled out to `dunstctl`.** It and its
`notifications.py` are archived under `~/bin/archive/`, with the rest of the
stock waybar theme; do not resurrect them.
- **A `Socket` whose connect failed never retries.** Quickshell 0.3.1 keeps
the failed `QLocalSocket`, so setting `connected = true` again (or toggling
it, or changing `path`) is a no-op: `setConnected` only connects when no
socket object exists. Retry with a fresh `Socket`, e.g. inside a
`LazyLoader` toggled off and on (`desktop/assistant/Assistant.qml`). A dropped
connection is different: `disconnected` clears the socket, so a later
`connected = true` works, until one of those attempts fails.
- **llama-server's `/slots?model=<id>` loads the model.** In router mode a
slot query for an unloaded model autoloads it into VRAM, so a status probe
is not read-only: one during the `ai` module's design put Gemma back on the
GPU. Pass `autoload=false`, which answers 400 `model is not loaded` instead.
Checking `/models` for `loaded` first is not enough on its own, because the
model can unload between the two requests.
- **A `QtQuick.Dialogs` dialog opens light on this dark desktop.** It paints
from the Qt Quick palette, which qt6ct leaves at Qt's default
(`custom_palette=false`): the dark is the `kvantum-dark` widget style,
which Qt Quick does not use. `Qt.labs.platform`'s dialogs fall back to the
widget ones in a QApplication, which `desktop/` is, and those take the
kvantum style. The colour picker in `modules/qtmaildir/` uses that; seen
and confirmed by eye.
- **llama-server's router loads a model with `POST /models/load`** and a
`{"model": "<id>"}` body, on the server root, not under `/v1`. Asking for
the loaded model answers 400 `model is already running`, and an unknown
id 404; both measured. With `--models-max 1` a load swaps out the
resident model for every client, so the assistant page does it only on a
click.
- **`busctl get-property` escapes non-ASCII as octal.** A name with an
accented letter reads `s "Do\303\250r"`-style, which no shell builtin
decodes. `busctl --json=short` returns real UTF-8; `userctl` pipes it
through `jq -r .data`.
- **`git config --replace-all` with an unmatched value appends.** With
`--fixed-value` and an old value no longer in the file, git adds the new
value and exits 0, so a stale edit duplicates a key instead of failing;
`--unset` of a missing value exits 5 with no message. `gitcfg` checks
with `--get-all` first.
## Theme
No component defines a palette. `udt-accent`, in the
`unified-desktop-theme` repo alongside this one, writes
`~/.cache/udt/udt-palette.qml` from its `palette.rasi`, carrying the whole
Catppuccin Macchiato palette plus the accent snapped from the current
wallpaper. `Theme.qml` parses and watches that file, so a palette edit
recolours a running shell with no restart.
What is left in `Theme.qml` is a fallback for before the file is read, and for
a machine without unified-desktop-theme. Do not grow it into a second palette:
that duplication is what generating the file removed.
The file is parsed rather than imported because a generated QML singleton
cannot be imported without a `qmldir` beside it, and the wal cache has no
reason to carry one.
There is one `Theme.qml`, in `shared/`, and each component holds a symlink to
it. It was four copies that had already drifted: `vm-manager` was missing
`surfaceAlt` and `volume-osd` was missing `green`, `yellow` and `surfaceAlt`,
so the shared file is the superset and the two thin ones gained properties
they never had. A symlink rather than a shared import path because a singleton
outside the config directory needs a `qmldir`, which is the same friction that
keeps the palette parsed rather than imported; quickshell follows the link and
resolves the singleton with no qmldir and no consumer change. Editing any
component's `Theme.qml` edits all three. Do not replace a link with a copy.
`shared/Workspaces.qml`, the workspace icon map, is linked the same way, from
`desktop/` and `window-switcher/`, so the island's flick and the switcher's
cards show the same icons. `Status.qml` and `Notify.qml` are shared links too, and so is `Glow.qml`,
the one hover glow (a `RectangularShadow`), linked from `desktop/`,
`window-switcher/` and `keybinds/`; a new glow uses it rather than a
solid ring or a thick accent border.
Hot reload does not follow any of them (see Reloading).
Glyphs are Nerd Font private-use codepoints and render with `Theme.iconFamily`,
pinned to Inconsolata Nerd Font, not `Theme.fontFamily` ("Noto Sans" has no
glyphs). Without the pin, fontconfig falls back per codepoint and the same
codepoint can be a different glyph in a different Nerd Font: the ethernet
codepoint `\uf6ff` rendered as a gamepad (`gamepad-variant` in JetBrains Mono
Nerd Font, absent from Inconsolata, whose ethernet is `\uef44`). A new glyph
must exist in Inconsolata Nerd Font and mean what it says; check the font cmap
rather than trusting the codepoint.
## Blur
Translucency is set in QML; the frosting is the compositor's. Hyprland blurs a
layer surface only when a rule names it, matched on the namespace the window
sets, in `~/.config/hypr/sections/decorations.lua`. A new component that wants
frosting needs its own `hl.layer_rule` and a distinct
`WlrLayershell.namespace`. Without the rule it still works, rendering flat
translucent.
History: a panel that should sit below waybar rather than over it used
`exclusionMode: ExclusionMode.Normal`, which respected waybar's exclusive zone
without the component knowing the bar's height. Measured with `hyprctl layers`
while waybar existed: waybar at `y=-540 h=42`, a `Normal` overlay on the same
screen at `y=-498 h=1038`, starting exactly where the bar ended, so the
backdrop never dimmed it. Waybar is gone, so the `desktop` drawer now starts at
the top edge of DP-1 and keeps `Normal` so a bar added later is respected.
Components that cover the whole screen use `ExclusionMode.Ignore`.
`window-switcher` has namespace `keystone-window-switcher` and a
`blur-window-switcher` rule of its own. The island has namespace
`keystone-island` and a `blur-island` rule.
## Reloading
| | How |
| --- | --- |
| a component's QML | quickshell hot-reloads on save; no restart |
| palette | regenerate with `udt-accent <wallpaper>`; watched, no restart |
| Hyprland binds, layer rules, autostart | `hyprctl reload` |
| a running shell in a bad state | `./reload.sh [component...]`, from the user's terminal |
`reload.sh` restarts components rather than hot-reloading them. The volume OSD
once drew only its label, with the icon, bar and device line clipped off, and
a hot reload fixed it while the code and the layout both measured correct, so
the stuck state lived in the running process. Do not run the script from an
agent's tool call: the detached `qs` it starts dies with the call (see
Verifying above), which leaves the user's desktop without its shells.
Hot reload covers adding and removing windows too: the keepalive window above
was added to a running shell and took effect on save.
Hot reload does **not** pick up a new component *file*. A directory's generated
`qmldir` is its import index, and it is only regenerated when a file that
imports that directory reloads, not when a file appears in it. Adding
`desktop/Switch.qml` while only editing files under `modules/` left it
invisible to the running shell, so `NetworkPage` and `BluetoothPage` could not
resolve `Switch` and the two modules dropped from the grid; the code was
correct and a fresh `qs` process rendered it. Force the rescan by changing a
file that imports the directory (`desktop/shell.qml`), or restart the shell.
Touch alone does not reload: the content must change.
Switching git branches under a running shell can drop a component until a
restart. Merging the island branch meant a moment on master without
`desktop/island/`; the shell hot-reloaded then, logged `Ignoring unresolvable
import ".../desktop/island"`, and kept running without the island after the
files came back, because a reappearing directory is not re-indexed. Restart
the affected component after any checkout that adds or removes a directory.
Hot reload does **not** follow a symlink either. Editing `shared/Notify.qml`
left both running shells on the old `sanitize`, while an edit to
`notifications/NotificationBalloon.qml` in the same pass did reload, so the
shell looked current and was not: a remote Markdown image the new filter
blocks was fetched anyway. After editing anything under `shared/`, force a
reload of every component that links it, the same way as above.
Hot reload **sometimes does not fire** after an edit, with nothing logged.
Twice in one session an edit was followed by no `Reloading configuration`
line, and the running shell kept the old code: an autostart glyph stayed
blank, an IPC function stayed "not found". The cause is not established.
It was first blamed on `sed -i` replacing the file, but the Edit tool also
writes a new inode (measured) and its edits usually reload fine. Before
trusting what the shell shows after an edit, compare `qs log -p <dir>
--log-times` against the file's mtime, and force a reload as above if none
followed. If even the forced change logs nothing, the process has stopped
watching altogether (seen once, after a few dozen reloads in a session):
only a restart, `./reload.sh desktop` from the user's terminal, brings it
back.
`qs -p <dir>` runs one directly. `qs -p <dir> ipc call <target> <fn>` reaches a
running one, which is how `SUPER+v` opens the VM drawer.
## Conventions
- GPLv2 only, with the header notice in every source file.
- Commits carry the reasoning, not just the change: several of the notes above
exist because a commit message explained why something was the way it was.
- No home paths in committed files. A gitleaks hook blocks them and has been
right every time; `~` in documentation, absolute paths only in the live
Hyprland config, which is not in this repo.
|