Notes for anyone changing roles/desktop/files/quickshell/ — how to test it,
the traps that cost real time to find, and the things already decided against.
Distilled from docs/qml-improvement-plan.md, a 43-package refactor completed
2026-08-08 (42 done, 1 measured and declined). That plan is in git history if
you need the reasoning behind a particular change; git log --oneline -- roles/desktop/files/quickshell is usually faster.
- The Ansible role is the vendor source of truth. Its deployed runtime is
~/.local/share/cybexos/runtime/quickshell; never edit that generated copy expecting the change to survive. User settings and overrides live in the paths documented bydocs/architecture/ownership.md. - Match the surrounding style. Do not run qmlformat (see "Already decided against").
- Theme values come from
Common/Theme.qml. Add a token rather than a literal when the value expresses a design role. - New shared components: PascalCase file in the directory that owns the
concern, and add it to that directory's
qmldir— a directory carrying aqmldiris no longer implicitly scanned, so an unlisted type fails at runtime as "X is not a type".tests/quickshell/qmldir.test.cjsenforces it. - Pure logic goes in a
.jsmodule inCommon/with a Node test intests/quickshell/— that suite runs in under a second without Qt. tests/runis the strict source gate: language-aware static analysis, Node tests, QML static/runtime checks, integration contracts, and the repository's other fixtures.update --fullruns it before deploying, and the Ansible role lints the tree before copying a changed one. See./tests/run --listand the operations guide.
The built-in authentication dialog uses Quickshell's Polkit service. Its presentation and startup checks are covered below; its authentication backend remains the system Polkit/PAM stack.
Run ./tests/run first; it needs no live shell. External widget tests require
sway for a disposable headless Wayland compositor (also installed by CI);
this is a test dependency, not a change to the desktop's compositor.
For a live deployment, keep
quickshell.service as the only qs process and use the shared safety harness
at both boundaries:
set -euo pipefail
source tests/lib/quickshell-live
cleanup() {
rc=$?
trap - EXIT INT TERM
qs_live_end || rc=1
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
qs_live_begin
ansible-playbook site.yml -e @/etc/cybexos/config.yml --tags quickshell
qs_live_wait_ipc 20 popouts close >/dev/null
qs_live_wait_ipc 20 popouts toggle t3code # or: audio, control, wifi, notifications, …
qs_live_wait_ipc 20 settings open notifications
grim -g "1020,50 400x420" /tmp/shot.png
qs_live_end
trap - EXIT INT TERMqs_live_begin compares the service MainPID with every pgrep -x qs result,
inspects command lines and cgroups, and only terminates a confirmed unmanaged
qs -d/qs -p developer process. qs_live_end requires the service to be
active, its MainPID to be the sole qs, and the current invocation journal to
be free of known QML/runtime errors. IPC readiness checks target the service PID
so runtime migration cannot send them to a dead default configuration. Never
replace this with pkill qs.
The Ansible role is the supported deployment path. A test that temporarily
edits the deployed tree must be trap-protected, restore the exact managed
manifest (including destination-only file removal), wait for IPC readiness,
and then call qs_live_end; tests/t3-contract-snapshot is the working
example. Do not commit while a throwaway live copy is deployed.
console.lognever reaches stdout or the qslog, under anyQT_LOGGING_RULES.console.warndoes reachjournalctl --user -u quickshell.service. A harness that must report a value writes a file:Quickshell.execDetached(["sh", "-c", "printf '%s' \"$1\" > \"$2\"", "sh", text, path]).- Offscreen harness:
tests/runpoints HOME/XDG paths at a scratch directory. Genericqmltestrunnercovers helpers; Quickshell's plugin is statically linked intoqs, so the production-component lifecycle harness runs through the real engine in CI and refuses to run beside an active localqs. If another direct source-treeqs -d/qs -pprobe is indispensable, stopquickshell.servicefirst and install a trap that terminates the exact developer PID, restores the service, and finishes with the same sole-PID and current-journal checks asqs_live_end. Never run the probe beside the managed service. - Triggering internal code paths live: add a throwaway
IpcHandlertarget to the deployedshell.qml. Cheaper than staging the real event, and it exercises the shipped code rather than a copy. - Keyboard:
wtype -k Tab/wtype -k spacesends real key events, which is how focus order and activation get tested end to end. - Updates drawer states without updating anything: write a run record
(
status.json,dnf.log,flatpak.log,firmware-events.log) under~/.local/state/cybexos/update/logs/<id>/and put the id in../current. The id has the formYYYYmmdd-HHMMSS-N-N. Set the record'sunitto a disposablesystemd-run --user … sleep 900unit, never a real service:statustreats an inactive unit as an abandoned run, and a stray Cancel stops whatever unit the record names. Restartquickshell.serviceso it attaches, rewrite the record with the same log prefixes to advance it, and remove the run andcurrentafterwards. - Pointer:
hyprctl dispatch 'hl.dsp.cursor.move({ x = …, y = … })'takes absolute screen-logical coordinates. Call it in a loop untilhyprctl cursorposagrees — the first call after the pointer has been elsewhere can land short. Withgrim -cthis makes cursor shape and hover state observable. - Multi-monitor without hardware:
hyprctl output create headless, then read the name back (HEADLESS-1). The shell keeps a bar mapped on every output, sohyprctl layers -jcan directly verify oneqs-barsurface per output and only oneqs-bar-popouton the output whose bar was clicked. - This Hyprland speaks a Lua dispatch dialect —
hyprctl dispatch 'hl.dsp.focus({ monitor = "<name>" })'. Plainfocusmonitor/movecursordo not exist. - Prove the probe has teeth. Reconstruct the pre-fix code in the throwaway copy and confirm the probe fails. A check that cannot fail has not verified anything.
compare -metric AE is not a pixel count in this ImageMagick 7 — it
returned 6.7e7 for a 120×80 crop. Use:
magick a.png b.png -compose difference -composite -colorspace Gray \
-threshold 8% -format '%[fx:mean*w*h]' info:and always check it against a deliberately broken variant before believing a
zero. Then subtract the noise floor: capture the same tree twice and diff
that too. Live values move constantly — the control centre differs from itself
by ~134k px eight seconds apart (CPU/RAM/temp), the battery popover by its time
estimate, several settings pages by their clock previews. A panel's outer edge
blends with whatever is behind it, so -shave 12x0 before comparing.
- An unqualified reference to another singleton's property lints clean and
throws at runtime. Inside a Quickshell
Singleton, qmllint cannot resolve the scope, sothreadMapwhereT3Threads.threadMapwas meant produces no warning — and every call throwsReferenceError, invisibly except in the journal. This shipped three times during the T3 split (WP5.1, WP5.2, and settle/snooze after Phase 5).tests/quickshell/t3-singleton-scope.test.cjsnow enforces a watchlist; extend the list when a new cross-singleton name appears. - A
Connectionshandler that matches nothing on its target is silently dead. qmllint has no opinion, the configuration loads, and Quickshell logs one WARN at reload and never calls it — so the code reads as wired and does nothing. The T3 façade makes this easy: views only talk toT3Code, so a handler for state that still lives onT3Drafts/T3Detaillooks right at both ends. Shipped three times (threadDrafts/userInputDrafts,newThreadConfirmed,detailThreadId). Re-export on the façade — a property binds, a signal needs its own declaration plus a relayingConnections.tests/quickshell/connections-handlers.test.cjsnow resolves every handler against its target singleton's real surface. - In-place mutation never re-evaluates a binding. Mutating an object or array in place is invisible to QML; reassigning it notifies. Both behaviours are useful — a memo cache wants the former, an invalidation wants the latter — but mixing them up silently breaks either the update or the performance.
- A counter written from inside a binding can feed back into the binding
graph. Instrumenting
iconSourcewithproperty intcounters drove the shell to 171% CPU and 6.6 GB and filled/run/user/1000with a 3.1 GB log, after which the restarted instance could not create its IPC socket. Count in a.pragma libraryscript instead — module scope is not a QML property, so nothing can capture it. Note such scripts are cached past a hot reload; changing one needssystemctl --user restart quickshell.service. - A
.jsimported without.pragma librarygets a separate copy per importing component. Fine for stateless helpers, useless for shared state. readonly property intsilently truncates. Card heights are text metrics plus padding and land on fractions; anintcost a pixel and shifted everything below it. Userealfor anything derived from text.- Quickshell does not watch
qmldirfiles. A qmldir edit needs a.qmltouch before it reloads. Loader.activedefaults to true, soonActiveChangedfires only for slots that evaluate false. Absence ofactive=truelines is a logging artifact, not a gate that failed.signal-handler-parameterscannot be satisfied forProcess.exited(int, QProcess::ExitStatus)— the enum is not registered with QML. The apparent fix (function onExited(exitCode) {}) silences qmllint and stops Quickshell calling the handler. Disabled in.qmllint.iniwith that reason.- Quickshell emits no
exitedat all when a binary cannot be launched — only the falling edge ofrunning. Anything reading exit status must handle a never-started process;Common/ProcHelpers.jshas the sentinel. - Popout lifetime:
PopoutHostlatches the Control Panel, so it is constructed once and never destroyed. Anything refcounted must key onvisibleviaCommon/Claim.qml, not on construction or destruction. - Idle CPU is not measurable with
topon a machine in use — sampling the live shell gave 0.12%–6.44% across windows of identical code. Instrument both trees and count timer firings in the journal instead. - A pixel-identical bar is not a working bar. WP4.3 shipped a module
registration regression that looked perfect in a screenshot.
tests/runcarries a duplicate-handler check because qmllint has no opinion on that and the failure mode is a shell silently running stale code. hyprctl cursor.movewarps the pointer without delivering hover to the client. NoMouseAreaunder it seesonEntered, andcontainsMousestays false — verified by probe, with the bar's ownHoverHandlerreportinghovered: trueand a live position at the same moment. The cursor shape still changes, so a screenshot looks like a real hover and is not one. A uinput virtual pointer emittingEV_RELa pixel at a time does generate real motion, but Qt's legacy hover path still did not pick it up here — so testing anything gated oncontainsMouseneeds a human hand on the mouse. What is testable without one: pin the raw hover state totruein the deployed copy and check what the bar-wide validation does with it.- Binding an item's visibility to a descendant's
visiblelatches it at false.visiblereads back effective visibility — the item's own flag ANDed with its parents' — so a wrapper written asvisible: child.visibledepends on itself and can never leave false. It looks correct as long as the child starts visible, which is why it survived review: only modules that turn on later (a track starts playing, updates appear, a tray icon registers) stayed missing. Bind to the underlying condition instead —Loader.active, notLoader.visible. - A defaulted property that a safety check depends on will eventually be
left unset.
BarIcon.hostlooked like panel wiring, so the idle module — which owns no panel — never set it, andBarTooltipsilently fell back to the localcontainsMouseit exists to second-guess. A missed exit event then stranded "Idle inhibit off" on screen with no path back to false.hostisrequiredonBarIcon/BarChip/BarTooltipnow andRequiredPropertyis an error in.qmllint.ini; the general lesson is that a null-degrades default turns a loud failure into a silent one. - Lock state comes from
hyprctl locked, never logind'sLockedHint. Nothing in this session sets the hint, so it reads "no" behind a working lock screen. A lock helper that times out must leave a running locker alone: stopping it unlocks the session with nobody at the keyboard, and staying locked is the only safe failure (cybexos-session-action). hyprctlspeaks the Lua config too.hyprctl dispatch Xrunshl.dispatch(X), so X must be anhl.dsp.*expression; a bareexitis nil and does nothing.hyprctl keywordis refused under a Lua config and still exits 0; set options withhyprctl eval 'hl.config({ … })', which fails loudly.HyprlandToplevel.addressis bare hex, while theaddress:selector needs the0xprefix.- A bare
qs ipc callreaches nothing. The shell runs by path (qs -p <runtime>), so there is no default configuration to find. Scripts, bindings and docs usecybexos-runtime ipc TARGET FUNCTION …, which resolves the path the service started with, development checkout included. luajitreading a script from stdin without-exits 0 after a failed assertion, so a heredoc fixture can never fail. Writeluajit - <<'LUA'.- FileView
setTextskips text it believes it already holds. It compares against the last bytes it read or tried to write, a failed attempt included, and a match emits neithersavednorsaveFailed, so a write guard waiting for one never clears. Settings and Notes track that text (storeText), settle without writing when the file already has the content, and retry identical content with one extra trailing newline. Quickshell logs a failed atomic commit (fsync or rename) and still emitssaved. Never callsetTextunder a live async write; areload()issued under a write is dropped, which is why Settings'reloadStore()defers it. - Qt refuses to clear
activeFocusOnTabon the focused item. A roving tab stop that follows the selection mustforceActiveFocus()the new target before committing it, or the old item keeps a second tab stop. - hypridle's
condition_cmdruns synchronously in its loop (0.1.8), so keep it fast and bounded. A failing condition skips the listener'son-resumeas well as itson-timeout, and any input cancels pendingcondition_retryattempts. - Python retries
poll()andsleep()after a signal handler runs, so a handler alone never ends an unbounded wait: register the loop's pipe withsignal.set_wakeup_fdand poll it too (xps-haptic-touchpad). pactltranslates the headers of its long listings ("Sink Input #" is "Afvoer-invoer #" in Dutch). Parse them underLC_ALL=C.- An EDS connect wait of 0 means "wait forever".
calendar-events.pyconnects its sources in parallel with a one-second wait, and at its deadline returns the calendars that answered plus asourceErrorsentry for each that did not.
A review of efficiency and robustness left these contracts in the code. Keep them when changing the surrounding files.
- Streaming transcripts update rows, not arrays.
T3ThreadPageandHermesTranscriptdraw from aListModelkeyed by message id and apply edit scripts (T3CodeHelpers.historyRowOps,HermesHelpers.listSyncOps); a token is onesetProperty. Per-row UI state (expanded, editing) lives on the page keyed by id. T3 detail histories stay sorted, soupsertHistoryinserts by binary search instead of re-sorting. Hermes stores messages in place: bind totranscriptRevision/transcriptChanged, not tomessagesByConversation. - Repeaters over derived lists take a structural key. Bar clusters,
indicators, workspaces, the drawer network list, and the
GitHub Inbox and repository rows parse their model from a JSON string of
ids, and delegates look up live data by id, so a value change flows through
bindings instead of recreating delegates. GitHub Inbox sections and drawer
Bluetooth sections are fixed models; drawer Bluetooth and Sound rows go
through
ScriptModel, which diffs them by device and node identity. Settings.applyLoadedassigns only changed keys (assignChanged) and ignores a reload whose bytes equal the last write. Reassigning an unchanged var key still notifies and rebuilds every bar module. A settings file from a newer schema is applied read-only and never saved over.- Popout slots are visible only while fronted, fading or requested. An
outgoing panel turns invisible when its fade ends, which releases its
Claims and visible-gated timers. A presented slot is fronted before its card turns visible; otherwise the latched drawer counts as visible for one turn, and its tab's claims start and release their pollers on every unrelated open. A spinner'srunningincludes its ownvisible, since the outgoing panel stays alive until it closes. - One
nmcli monitorlives inNetworkStatus;EthernetStatelistens to itsmonitorEvent. Both 30 s safety polls run only while it is down, and restarts back off 5 s → 60 s. Battery health follows the same rule withupower --monitor-detail: a safety poll runs only while its event stream is down. Tailscale polls every 120 s for plainacquire()claims and every 30 s foracquireLive()views, neither while idle; a missing binary drops it to an hourly probe, and a view that opens probes at once. - Tailscale sign-in uses the Network overlay for first-use setup and the
shared
Tailscalesingleton for the pending session. Continue releases the overlay's keyboard focus before browser handoff or a polkit prompt. Login URLs stay in memory; closing the dialog keeps sign-in running. A two-second status poll lasts up to five minutes, and only a successful status snapshot with a Tailscale address reports connection success.NeedsMachineAuthdisplays administrator approval separately. Stop connecting usesdown, preserving the account. Commands are bounded bytimeout; permission failures retry once throughpkexec, without changing the machine's operator or polkit policy. First sign-in consumesup --jsonincrementally; reconnection uses bareupto preserve non-default preferences (even adding--jsonchanges Tailscale's preference checks). The existing-account path also recognizes a standalone HTTPS authentication URL on stderr. - The Network panel's live figures cost no process per sample. Throughput
reads
/sys/class/net/<if>/statisticsthrough FileView on the 1.5 s tick, andprimarycarries those live counters; latency comes from two long-runningping -n -Oprocesses. The device/route/profile/scan snapshot runs onNetworkStatus.monitorEvent(debounced), on a change in the scanned SSID set, after actions, and on a 10 s safety poll. All of it runs only while a view holds the panel. - Plugin discovery watches
plugins.json, whichplugin updatetouches. Package trees are polled every 5 min only while a plugin is enabled (2 s while Settings is open), never while idle, and a package is rehashed only when its stat signature in<runtime-root>/.revisions.jsonchanges. A registry error keeps the last good plugins. - Long-lived helpers are bounded.
ghreads, brightness reads/writes, matugen,calendar-events.py, wallpaper thumbnails and the plugin scanner each have a timeout or watchdog. Helpers settle on the falling edge ofrunning, so one that never starts cannot wedge its queue; that includes the Reminders list, the plugin writer, the launcher's search processes and the brightness re-read a mid-read refresh leaves pending. - Settings, Notes and launcher usage write asynchronously. Settings and Notes keep one write in flight (see the FileView trap); launcher usage is written 2 s after a burst of launches, and synchronously if the shell goes first.
Common/Activity.qmlis the one idle and power signal.idlecomes from anIdleMonitor(300 s, respecting inhibitors, off outside a Wayland session);onBatteryandpowerSaverfollow UPower and the power profile;resumedfires on the first input after idle. Background pollers stop while idle and refresh what went stale onresumed. A timer longer than an idle spell (wallpaper rotation) keeps running, and work that falls due while idle is owed toresumed.- GitHub gates scheduled timers on
scheduleActive: the module is on, the session is not idle, and the network is not known to be down. Manual refreshes are never gated. - Poll only for a consumer. Updates checks in the background only for its widget or its notifications; opening the Updates panel or the drawer Overview refreshes a stale count. Weather fetches only for a set location with its widget on or a Day sheet claim; the calendar polls only for a Day sheet claim.
- Updates retries only what failed. The retry budget resets on the online edge, startup, a manual refresh or an all-success check, never on a scheduled poll, and each source has its own notification baseline. dnf's answer is reused while the repomd/repo/rpmdb signature is unchanged; manual and post-run checks force a real read, and one happens at least every 6 h.
- Never reload a watched FileView on a timer. Each reload rebuilds its inotify watches, and the directory watch already sees creation and atomic replacement. Poll only while the file is missing, and make sure its directory exists (Recorder, Dictation).
- Parsed commands set
LC_ALLthrough the Processenvironment, not anenvprocess per run. - Integration sockets exist only while wanted.
T3Connection.enabledandHermesConnection.enabledfollow their bar module, or their panel while it is open, andT3Connection.connect()is the single gated entry. Naming a connection singleton constructs it, so ShellHealth checksSettings.modsfirst. Backoff starts over only once a link has proven healthy (T3: its first shell snapshot viamarkHealthy, or 60 s connected; Hermes: 60 s), never merely because a socket opened. T3 retries rest while the machine is known offline (a loopback server is exempt) or idle, Hermes's only while idle. An expired T3 Connect session is a state, not an error: the ticket helper exits 3, the link goes signed-out without retrying, and the panel offers Sign in. - Nothing ticks faster than it reads. The recording mark steps with
Recorder.elapsedinstead of an infinite animation; the mic meter samples at ~15 Hz into whole pixels; reminder countdowns wake atCountdownHelpers.soonestChangeMs; HH:mm captions use a minutes clock that runs only while visible. - Power saver is part of
Theme.reducedMotion(Activity.powerSaver). The compositor side iscybexos_power_saver()inlooknfeel.lua, whose state lives in_Gso a config reload reapplies it.
Common/— singletons (services, settings, theme), pure.jshelpers, and the shared controls both other directories draw (Toggle,HSlider,NotifCard,NotifIcon,NotifActions).Bar/— the menubar, the popout host, and 13 modules underBar/Modules/sharing aBarModulebase.Cluster.qmlis what turns a run of adjacent modules into one shared pill (seeLayoutHelpers.groupModules);Bar.qmlowns the furniture at either end and the fit pass.Popovers/— panel contents, all built onPopoutPanel/Surface.Settings/— the settings window; rows build onSettings/SettingsRow.qml.tests/quickshell/— Node tests.shell.cjslocates the source tree;load("X.js")pulls a helper out ofCommon/.
Two conventions worth knowing: a settings row that names a settingKey gets
its value, dirty state, commit and undo from the base, and per-surface styling
travels as a single var object (Theme.switchRow, a card's style) rather
than as a dozen properties.
Common/LauncherProviders.qml owns command-palette routing, results and side
effects; LauncherView.qml only renders its normalized rows. Every open and
every close resets to the Apps tab; Emoji, History (clipboard), and Actions
are discoverable tabs beside it in a compact 460px card. The strip is 34px
tall, the search field is 44px, and an up-to-eight-row viewport uses 42px rows
with 28px icons. The Apps tab keeps every visible desktop entry in its model
and scrolls inside that fixed viewport; keyboard selection keeps the active
row in view. Rows show one line only; action subtitles and keywords remain
searchable metadata but do not add visual bulk.
The launcher's layer surface is the card's full-height envelope plus its
entry travel, anchored top-left at offsets from the output edge, not the whole
output: the compositor blurs every pixel of a blurred surface. Clicks outside
it clear the HyprlandFocusGrab, which closes the launcher.
Left/Right cycle tabs, as do Ctrl+Tab and Ctrl+Shift+Tab. Up, Down,
and result-navigation Tab wrap at the list ends; PageUp/PageDown jump six
rows and clamp to the first or last result. Home, End, Alt+1…8, immediate
Enter, and clipboard Shift+Delete remain available. Escape clears a
non-empty query and returns to the active tab's full results; a second Escape
closes the launcher. The result highlight and its glyph color change
immediately without a transition. Switching tabs clears the search field so
results never carry across provider boundaries.
Typed prefixes temporarily override the selected tab: / files, > command,
= calculator, @ web, $ windows, ; clipboard, : emoji, and ! actions.
Removing the prefix returns to the selected tab, so the compact tab strip does
not displace the existing keyboard-first routes.
Clipboard history is collected by cliphist; Shift+Delete or right-click
removes the selected clipboard entry. Emoji names come from Fedora's
unicode-emoji data. Activating an emoji copies it and, once the launcher has
released keyboard focus, pastes it into the previously active window. Both
providers degrade to a readable empty-state error when their package is
unavailable.
launcher-actions.json at the shell root is watched for changes. Each user
action must provide a display name and an argv-style command; a string shell
command is rejected deliberately. For example:
[
{
"id": "notes",
"name": "Open notes",
"subtitle": "Open the notes folder in Nautilus",
"keywords": ["documents", "writing"],
"command": ["nautilus", "/home/alex/Documents/Notes"]
}
]Every surface in the shell — the settings workspace, T3 Code, the GitHub
workspace, the control centre, the network and audio panels, the notification
centre and its toasts, the launcher, the OSD, the shortcut sheet — used to be
a stack of filled, bordered cards on a lighter surface, and two of them were
in a face of their own. They all follow the menubar now. Four rules, and
Common/Theme.qml carries the tokens:
- One surface. A dialog sits on
Theme.panelSurface(the shell's deepest surface, glass-aware) with no card stacked on it.Theme.chip/Theme.chipHoverare the only fills left inside: a text field, a row that is current, a segment that is taken. - A section is a label plus a hairline.
Settings/SectionHeader.qmlis the shape — uppercasefontMicro, letter-spaced, then a rule to the edge. T3's inbox groups and the GitHub workspace draw the same mark inline.SettingsGroup.qmlis a layout, not a Rectangle; there is nothing left to paint. - One accent, four places. The current workspace pill, a live status dot
and its working label, the current page's icon in the settings rail, and an
on-switch track or selected swatch ring. Never a nav-row background, never a
selected segment fill, never a wash behind a title, never a slab behind the
selected launcher result or a connected device.
typography.test.cjsbansaccentBg*/accentSoft/accentSubtle/accentContaineras acolor:orborder.color:shell-wide, with a short allow-list naming the four fills that earn it: a slider's value readout, a switch or quick-toggle track, the one primary action per panel, and the current-day / current-workspace pill. - One face.
Theme.fontMenu, the Typography setting, everywhere.T3Theme.fontUiis the T3/GitHub indirection.Theme.fontSansis now only whatfontMenufalls back to; naming it in a view is how a surface opts out of the setting, which is exactly the bug this closed, sotypography.test.cjsbans it outside Theme itself.
Metrics live in Theme's ---- dialog metrics ---- block: panelRadius follows
Settings.barRadius, so squaring the menubar squares the panels under it;
panelRowHeight 28 is a settings row, listRowHeight 34 is one menubar-tall
list row, panelTileHeight 48 is the occasional two-line form,
sectionHeaderHeight 22 is the mark above them, and panelHeaderHeight /
panelFooterHeight are a panel's title block and its one-line footer.
Two aliases changed meaning rather than value: Theme.cardFill,
Theme.tile and Theme.insetSurface now resolve to Theme.chip, and
cardRadius / rowRadius / tileRadius to chipRadius. There are no cards
left, so the names that meant "a container with a fill and a border" mean the
menubar's resting chip — which is why most panels needed no edit of their own.
popRadius follows panelRadius. surfaceRadius did not move: it is
Hyprland's window rounding (roles/desktop/files/looknfeel.lua) and the Hug
corners that must match it, and bar-geometry.test.cjs pins the pair.
Two things this pass had to fix, both worth remembering:
- A fixed pixel lane beside a text label breaks when the face changes. The
GitHub inbox positioned its Settled count at
leftMargin: 62, which cleared the word only in a proportional face; in JetBrains Mono the two overlapped. Anchor a count tolabel.right, never to a measured constant. Lanes that clear a fixed-size icon (the 30–32px ones) are fine. - Compact a row as a layout change, not a token change. T3 first moved its
inbox to one line; GitHub later followed. GitHub's Inbox is deliberately only
a coloured status glyph and meaningful title; workflow rows prefer GitHub's
run display title over generic workflow names such as
CI. Repositories and commits keep their context in bounded lanes beside the title. Simply shortening the old two-line card would draw its detail through the next section header.github-inbox-structure.test.cjsrequires all three lists to use the shared flat row and pins the Inbox's quieter status treatment separately.
SettingsHelpers.semanticPalette also gained a real step at every level. It
built the ladder with ensureContrast, which only ever raises a colour, so a
Material palette whose onSurfaceVariant already cleared 7:1 returned the same
tone for all five steps — in wallpaper mode every label, value and piece of
metadata rendered identically. paletteTone folds the tone back toward the
background when it over-clears, so each step lands on its own floor.
The 2026-09-03 redesign ("Quickshell Menubar", Claude Design project
8cf85161, direction 2) introduced the attached surface family:
- The Control Dashboard (
Popovers/Drawer/) is one edge-drawer surface with six tabs — Overview · Sound · Network · Bluetooth · Power · Notifications. Every established popout name (control,audio,wifi,bluetooth,tailscale,battery,notifications) still works from IPC and the bar; each one presents its tab ofDrawerPopover.qml. The tab is derived fromPopouts.currentNameat creation, and the drawer's own tab strip navigates by reopening the canonical name for the wanted tab (PanelRegistryData.nameForTab), so the bar's held states, hover-crossing and the module-ownership sweep all keep working unchanged. - Updates is a dedicated edge drawer (
Popovers/UpdatesPopover.qml). The widget andupdatesIPC name open its full pending, running, completed, and failed views rather than deep-linking to Overview. It still carries the new drawer template's width, attached geometry, Hug corners, palette, and type. Every state uses one layout. A one-line header states what is happening. Fixed System, Apps, Firmware and CybexOS rows move from a count to progress to a result. There is one primary action (Update, Restart now or Try again). A collapsed Details section holds package names, the live transaction, dnf's own error and the recovery point. Keep backend vocabulary (dnf, Flatpak, poll cadence, log paths) and negative results ("no restart needed") out of the rows and header. - Firmware installs inside the update run, never in a terminal. When
firmware is pending,
Updates.run()passes--firmware. The durable worker then runscybexos-firmware-update(libfwupd over D-Bus, as root) after the packages and streams JSON events tofirmware-events.log, which the panel reads like the dnf and Flatpak logs. fwupd's device requests (such as replugging a dock) appear as a card under the Firmware row. Capsules staged for the next boot turn the run's restart recommendation on. On battery, a device that requires AC power is left out of the run, and the row asks for power instead of letting fwupd fail the flash. A firmware failure never fails the package update; the row explains it. - T3 Code is a separate attached panel, not a seventh status tab. Its
existing
t3codepanel name, bar ownership, source, and IPC route are unchanged; unlike the status drawer, it is not pinned to a screen edge, so it follows the T3 widget when that widget is reordered or moved between bar sections. It uses the widerTheme.t3MaxWidthmeasure and hugs the active page's content until it reaches the host's usable-height cap, at which point only the page's content viewport scrolls. Inbox search and connection chrome stay fixed around a scrolling grouped list; thread headers and response/composer controls stay fixed around the transcript; New Thread scrolls its form below a fixed header. Below 360px of effective content width, inbox rows become two-line tiles and composer reasoning moves into Run settings. Picker geometry is clamped to the drawer body and New Thread reserves popup room in its own scroller rather than extending a transparent surface tail. - The Day sheet (
Popovers/DaySheetPopover.qml) hangs under the clock (and the weather pill): big time, today's sky, a Monday-first week strip with per-day forecast and calendar event dots, and the next three events. - Clock and Weather options share
Settings/WeatherLocationPicker.qml. City search uses Open-Meteo geocoding (GeoNames), debounces typing, and distinguishes matches by region, country, and coordinates. Typing previews; Enter applies a sole match, while ambiguous results require selection. A selection saves the name and both coordinates in one settings write. Clearing or dismissing a search preserves the saved location; timeouts and stale replies cannot overwrite it. Manual coordinates remain available. This sets the forecast location, not the system clock's time zone. - The registry gained two flags:
attached(flush under the bar, squared bar-side corners, Hug-corner bridges drawn byBar/PopoutHost.qml) andedge: "right"(pinned to the screen edge instead of centred on the trigger). Both are read by the popout host; detached panels are untouched. - The visual system moved with it: warm charcoal surfaces (
#1a1917bar and panel, chartreuse#d3d283accent), Figtree as the default UI face, andTheme.fontNumeric(Geist Mono) for every instrument reading — the clock, percentages, meters, resets. Clock+weather group into one filledtimepill; notifications joins the filled vol/wifi/bt/battstatuspill by default and can be separated under Widgets → Notifications. Grouping still follows adjacency (SettingsHelpers.MODULE_GROUPS/FILLED_GROUP_KINDS); the usage chips carry a 2px remaining meter; the bell wears an unread dot instead of a count. - The pre-drawer popovers (
AudioPopover,WifiPopover,ControlCenterPopover, …) are no longer reachable from the registry but remain in the tree with their tests until a deliberate removal pass.
The 2026-08-15 redesign ("QuickShell Menubar", Claude Design project
facd7f56) replaced an opaque bar and its bar-fused popouts with translucent
glass and detached panels. What that added, and what it needs:
- Pinned shell fonts. The UI typeface remains configurable. Generic
interface icons use bundled Tabler 3.48.0, loaded once by
Common/TablerIcons.qml; no icon font installation or font-cache refresh is needed. Product marks still useBrandIcon, and application icons use the desktop theme. - Icons use an explicit registry.
Sym { name: "wifi" }resolves throughCommon/TablerGlyphs.js. Existing semantic names remain as compatibility aliases for built-ins and plugins; canonical bundled Tabler names also work. Unknown names show help-circle and empty names draw nothing.Symreserves a square slot and always draws the outline variant, including active states and playback controls. Selection uses colour, backgrounds, borders, labels and checkmarks; pinned thread actions use accent ink and favourite stars use amber. Icon colour fades, press feedback and spinners remain. Legacyfill/animateFill/glyphFill/symbolFillinputs are accepted but inert, as aresymWeight/grade. The filled font is no longer shipped.tests/quickshell/tabler-icons.test.cjschecks names and actual codepoint coverage in the bundled font, without system dependencies. Brand/application artwork and functional shapes such as switch tracks and progress meters retain their own presentation. Update assets withscripts/update-tabler-icons; seeassets/tabler/README.md. - Blur is the compositor's.
roles/desktop/templates/looknfeel.lua.j2defaults to one blur pass to reduce desktop GPU work while keeping animations and display refresh rates unchanged. Both Ansible deployment and the ISO desktop package render this template. It exports the namedquickshell_blur_rulematching theqs-*namespaces. The Appearance switch calls that handle throughhyprctl eval; its initialenabledvalue is read from the persisted JSON so compositor reloads retain the choice. Layer namespaces stay fixed because changing one after a Wayland surface is connected does not update the compositor rule safely. - Nothing that floats over the desktop may draw a drop shadow. Blur is
applied per pixel of the surface, and every one of these layers is larger
than the shape it draws — the menubar's runs past the slab to leave room for
tooltips, a panel's runs past the card. Anything painted into that margin is
blurred with the shape, at the full size of the layer, so a shadow does not
read as a shadow: it reads as a haze band the height of the whole surface.
Both the design's
0 20px 50pxshadows shipped that way and both were reported. Raisingignore_alphaonly trims the falloff — the shadow is at full strength directly under the shape, which is exactly the band you can see. Glass over a real blur already reads as floating; the hairline border and the rim highlight do the rest. Glows inside a surface (the focused workspace pip, the T3 running dot) are fine — they composite over the glass, not into the margin. - Render semantic surfaces, never raw variants.
Theme.barSurface,surfaceStrong, andsurfaceMenuselect translucent glass or their opaque references fromSettings.glassEnabled. Directly paintingTheme.glass*,popBg, orbarBgbypasses that switch. Modal scrims are deliberately separate: they remain translucent safety layers when glass is off. - Wallpaper mode is one validated Material palette.
Common/Palette.qmlruns Matugen's tonal-spot scheme for the selected wallpaper, whitelists the semantic roles inPaletteHelpers.js, and atomically caches both light and dark variants at~/.local/state/cybexos/shell/wallpaper-palette.json. Theme changes select the cached variant. The menubar background remains the user's independent bar-color choice while its accents follow this palette. Missing or malformed Matugen output leaves the user's mode unchanged and renders the stored fixed colors as fallback. Copy-bearing tones are still forced to a 4.5:1 floor against their opaque reference surface. - Bar style is explicit.
hugis the default edge-attached slab with localQtQuick.Shapesconcave corners;floatingalone uses the stored gap and radius;attachedis full-width and square. Hug/attached reserve exactly the bar height, and the decorators travel with auto-hide without joining its input mask. - State layers are shared.
Common/StateLayer.qmlsupplies the 8% hover and 12% pressed/focused overlay used by bar primitives, workspace targets, shared actions, toggles, and settings controls. Controls retain their press scale and accessibility behavior. - One spring for continuous motion.
Theme.springCurvedrives controls and in-place movement. Bar popouts use a faster directional enter/exit and a lower-overshoot morph between triggers. Colour and opacity never spring — an overshooting fade reads as a flicker — so they use the ease curves. - The launcher is always keyboard-ready. Its view and first eight
alphabetically sorted apps are constructed at shell startup, while
Super+Spacereaches it through Hyprland's global-shortcut protocol instead of spawning an IPC client. It takes exclusive keyboard focus while mapped, forwards an early character or Enter across the mapping frame, and never stages result rows behind an animation. Launcher-only motion is brief and purely visual. - Schema 7 adopts the softer type and density pass. A stored
Urbanistvalue from an older schema follows the newGoogle Sans Flexdefault; OPPO Sans, IBM Plex Sans, and JetBrains Mono remain explicit choices. Shared popovers gain modest width and padding, metadata floors at 11px, and soft inner hairlines recede while outer surface boundaries remain intact. - Schema 6 adds bar style and palette mode. A v5 attached bar remains
attached. A v5 floating bar adopts Hug only when height, radius, and gap are
pristine; custom geometry remains floating. Old wallpaper-accent users and
untouched colors adopt wallpaper mode, while active custom colors select
fixed mode without discarding either stored choice. Module order is never
part of this migration.
SettingsHelpers.adoptRedesignstill gives a v3 file the schema-4 geometry only where the user never moved it.
- Settings use
settingsRowSpacingfor ordinary rows,settingsContentSpacingfor related rich content,settingsSubsectionSpacingbefore a subsection, andsettingsGroupSpacingbetween groups. Do not use a page's group gap inside a compact run of controls. SettingsSubsectionowns its heading and leading separation. Put it inside aRevealerso the separation collapses with the subsection. Keep full-width content forSettingsRowchildren; setinsetContentonly for rich content that does not already reservesettingsMarkInset.- Standard rows keep the modified-state gutter in wide and stacked layouts. Picker height follows its wrapped pills at either width; narrow captions sit below the control. Reset lanes remain reserved. Section headings reserve reset geometry even when clean; non-resettable subsections opt out of the trailing lane.
- Use
SettingsFieldfor native settings text inputs, including standalone forms.SettingsTextRowretains ownership of commit/reset/persistence wiring. Plugin-kit fields continue using their configurableUi/Commonsstyling. SectionLabelowns the bounded label/count/rule layout used by ordinary popovers and the T3/GitHub group headers. Product-specific colors remain overrides; their list row implementations remain separate.Theme.accentis the chosen fill/swatch color.Theme.accentTextis its contrast-adjusted foreground counterpart for native copy, icons and focus outlines. Do not darken the stored accent to make a light-mode label legible. T3 and Hermes retain their existing independently adjusted accent roles.
- qmlformat one-shot reformat: most files would churn and the tool fights
the deliberate hand-wrapped style. Revisit only as a dedicated commit with a
tuned
.qmlformat.ini. - Restarting the shell on every converge: Quickshell hot-reloads, and a
restart is more disruptive than the problem it solves. A converge that
changes the deployed tree or unit restarts once from the complete tree,
because sequential copies can trip rejected intermediate hot reloads, and
verifies it; an unchanged converge leaves the shell alone. Installing a new
shell font is the other exception: Qt does not add a newly cached face to an
already-running process, so the apps role
try-restarts Quickshell after a font install and leaves an inactive service alone. - The remaining perf items (toast countdown timer, memoising
Notifs.iconSource, aClocksingleton, a launcher token index): measured 2026-08-08 and declined. Two premises were already false in the code, and the third does not reproduce — with eight notifications the icon lookups go 8 → 24 → 96 and then flat, identically with and without a memo. Do not reopen without new numbers. - Merging the remaining list rows into one
ListRow: they differ more than the shared action buttons did. Reopen only if a fifth consumer appears. Theme.fontSans→Theme.fontMenuin the launcher and toasts: those are overlay surfaces, not menubar chrome, so they follow the general UI face and do not track the menu font setting.typography.test.cjsenforces the split.
Still open: deeper QML state-machine coverage. The mandatory runtime stage
now exercises helpers under qmltestrunner-qt6 and constructs, mutates,
signals, and destroys production controls under the real Quickshell engine in
CI. The next targets are the Settings load/merge/save cycle and the
T3Connection process/socket lifecycle against controlled test doubles.
Bluetooth has its own tab immediately after Network by default. Existing drawer
settings gain the new tab beside Network while retaining their saved order and
visibility; it can then be hidden or reordered in Drawer settings. The Bluetooth
bar button and popouts open bluetooth open this same tab.
The tab lists connected and paired devices for the default adapter, with battery levels when available. Opening the tab while Bluetooth is on automatically starts a 60-second discovery session and shows named nearby devices. Unpaired discoveries with empty, address-only, UUID-like or hexadecimal identifier names are hidden; paired and connected devices remain visible even without a readable name. Turning Bluetooth on while the tab is open also starts discovery. Stop scan ends it early, and Scan again restarts it after it stops; revisiting the tab starts a fresh scan. Pairing supports PIN entry, passkey entry/display and code confirmation inline; successful pairing trusts and connects the selected device. Errors remain visible for retry. Closing the drawer, switching tabs or powering off Bluetooth terminates the tab's helper, rejects pending prompts, cancels its pairing attempt and releases its discovery session. Other applications' scan sessions and default pairing agent are left alone.
bluetooth-tool.py uses the existing python3-gobject dependency and BlueZ's
application-scoped agent API. tests/bluetooth-tool.py checks input validation,
authorization, cancellation, action failures and discovery lifetime without
operating the host radio.
Settings → Bar opens with a live preview of the bar pinned above the page: the wallpaper, the bar at its position, style, height, gap, radius and background, and each section's enabled widgets as their icons. Clicking a widget there opens its options. Below it, the Widgets group lists Left, Center, and Right as rows of compact pills with leading icons (no cards). This includes widgets whose runtime conditions currently hide them from the bar. Plugins without a declared catalog icon use the extension symbol. Clicking a pill opens built-in or plugin settings in an embedded, scrollable dialog; closing it returns focus to the pill. Each pill's ⋯ menu offers Widget settings…, Move earlier/later, Move to Left/Center/Right, and Remove from bar. The bar's Layout, Background, and Behavior rows follow on the same page. This interaction design follows Noctalia's legacy QML bar editor; our implementation uses Cybex components and storage.
Disabled widgets sit in the Add widgets tray below the sections. A click adds one to its own section; its ⋯ menu picks another. Control Center is a normal widget: the Fedora button can move, be removed, and be restored. Its options contain the tab, overview, and behavior controls. Settings search opens this widget dialog too; there is no separate Control Center sidebar entry.
Schema 24 adds the Fedora widget at the right edge of older layouts and keeps
all other widget placements. Schema 25 retires the first built-in Model usage
widget (usage), its drawer tab and its modOpts.usage/pollMax settings.
Schema 26 adds modelusage, the Model Usage widget vendored from the
digitalpals.model-usage Omarchy plugin (see ModelUsage/README.md), at the
start of the right section of older layouts. It starts on when the install
has connected widgets. Its settings are modOpts.modelusage; sources,
credentials and cost servers stay in the panel's own forms, which Settings
opens.
Drag a pill to reorder or move it between sections. The drag ghost and insertion
marker follow wrapped grid positions and the arrangement scrolls near its edges.
Alt+arrow keys reorder; right-click, Menu, or Shift+F10 exposes placement,
earlier/later, settings, and removal actions. Plugin blocks match the native bar:
before built-ins on the left and right, after them in the center. Drop indicators
snap to those supported boundaries. Common/WidgetEditor.js translates visible
gaps to stored indices without changing disabled entries or compaction preferences.
Removal retains placement and settings and offers an eight-second Undo that
restores only that widget. Plugin success is confirmed by both the write result
and the refreshed membership/section before showing success. Section-specific
plugin adds enable and place the instance in one atomic registry write. The settings
writer skips already-persisted bytes before taking its in-flight guard: FileView
does not emit saved for an identical setText, which would otherwise block
subsequent edits after opening a form that re-applies an unchanged value.
The layout actions menu contains presets, plugin management, built-in layout reset, and layout Undo. Presets preview their enabled built-ins before applying, preserve placement and plugin preferences, and use the existing eight-second undo.
Plugin widget details expose width, saved/default setting values, and an
advanced key/JSON-value field. configure-widget changes widget enablement,
width, or the destination of a newly added widget atomically. Removing a widget
does not disable its package services or sibling instances. Settings are merged
through the plugin registry, never written into shell.json. The Omarchy plugins
page retains package installation, updates, cloning and removal; shared plugin
appearance controls live under Appearance.