Repository navigation
Feat/neon island - #10
Merged
Merged
Conversation
The previous design package described a bar-and-panel shell (SystemRail, EventLog, three-island AmbientBar) that no longer matches the code or the intended product. Three separate documents described the same redesign under three different component names, and one of them named DynamicIsland as the component to refactor -- which is dead code. Add shell/docs/target/ as the single authoritative spec, derived from the four reference diagrams: images/01-notch-idle.png unexpanded / idle images/02-notch-expanded.png expanded / hover images/03-command-center.png adaptive CCC images/04-calendar-timeline.png Calendar C neon-notch-spec.md records what each image specifies and states an explicit interpretation wherever an image is ambiguous. Where prose and image disagreed, the image won -- notably the CCC is a two-column layout with the notch header retained, not the single accordion stack the retired PDF described. The withdrawn [GRID] toggle is called out, since an earlier document still proposed it. colors.md documents that the palette change is a semantic remap rather than a swap: green stops being the accent and becomes status-only, magenta takes over the attention role, and the neutral grayscale ramp becomes navy. Remove: dynamic-island-event-log-morph.md, repository-audit.md (pre-Nix bar.qml baseline), hardcoded_offsets_analysis.md, and the untracked migration/ and design/ planning sets. Reconcile design/ against the target and record known divergences rather than leaving them undocumented. Rewrite the delivery tracker around the new implementation order, including the two subsystems the target needs that do not exist yet: calendar event data and disk telemetry. Exclude docs/ from the runtime package; the reference imagery would otherwise add ~4 MB to the store closure.
Two defects prevented the notch from opening the Command & Control Center and broke the architectural rule that surfaces never spawn processes. LivingNotch called OverlayController.toggleCommandCenter() and then emitted toggleCommandCenterRequested, which AmbientBar forwards to that same toggle. Each click therefore toggled twice and returned to the closed state, so the CCC was unreachable from the notch at all. Three sites were affected: the hover battery target, the hover rail target, and the master pointer area. The Command Deck trigger had the same duplication, but openCommandCenter's idempotence hid it. LivingNotch now emits signals only. AmbientBar is the sole instantiator and the sole consumer of those signals, so it remains the single place that drives OverlayController. The remaining OverlayController uses in the file are reads of activeSurface plus the distinct close-before-open-calendar call in toggleCalendar(), all of which are left intact. CommandCenter declared four Process objects duplicating SessionService's commands. Only lockProcess was still reachable, and the lock button fired it directly, bypassing the dry-run guard, the isBusy state machine, and the sessionActionTriggered/Finished signals. The other three were dead. The button now goes through SessionService.lock() and closes the overlay first, matching the ActionRegistry path. Both Process objects and the now-unused Quickshell.Io import are gone. Also drops a commented-out execDetached call in ActionRegistry that documented the wrong approach for the same action. Verified with qmllint against Qt 6.11 and quickshell 0.3.1 import paths: warning counts are unchanged on all three files and the desktop tree has zero hard errors.
…lit brain Deletes 2016 lines of code that could never run, then reconciles the two conflicting height sources that were partly caused by it. Dead code. AmbientBar carried a 398-line legacyCompatibilityLayer pinned to visible:false, width:0, enabled:false. It preserved the old three-island layout for a test suite that greps for element ids rather than loading them. Deleting it also removed the only instantiation of DynamicIsland.qml, which was a near-verbatim fork of LivingNotch.qml including the MPRIS selection tier, the notification rows, and the equalizer bars. Seven registered components were never instantiated anywhere: the six three-island widgets (Workspaces, Network, Volume, Battery, Clock, Bluetooth) whose content LivingNotch now renders inline, plus WindowTitleWidget. Also removed FloatingRadialSettingsNode.qml and NewColorWidgetPoC.qml, neither of which was registered or referenced; AmbientGlitch.qml, registered but never instantiated; and a desktop copy of blume-logo.svg that nothing loaded, as the greeter uses its own copy under greeter/resources/. All qmldir entries were then re-checked in both directions: every entry resolves to a file, and every registered component is instantiated. barHeight. Theme.barHeight was 36 and Settings.barHeight was 32, and the two governed different things -- the bar geometry used Theme, the four popup offsets used Settings. Changing the setting in the radial menu moved the popups and left the bar alone. The `- 6` appearing throughout was the tell: a 36px bar was being squeezed into a 30px notch. Settings.barHeight is now the single source of truth and defaults to 30, the target notch height, so LivingNotch.compactHeight drops the fudge entirely. AmbientBar's host height and the four popup offsets both derive from it via a new Theme.notchHostPadding token, so they cannot drift apart again. AccordionSection gets its own accordionHeaderHeight instead of borrowing the bar height, since an accordion header is not a bar. Theme no longer declares barHeight at all -- a user-changeable value does not belong in a token table. Two dead feature toggles. qmllint surfaced RadialSettingsModel reading Settings.featuresSystemRail, which does not exist, so that toggle read undefined and displayed DISABLED permanently. It targeted the System Rail surface that was consolidated into the CCC, so it is now featuresCommandCenter and gates the CCC host window. featuresCommandDeck existed but gated nothing; it now gates the Command Deck loader. Both were previously no-op controls. Verified with qmllint: zero hard errors across the desktop tree, and no accesses to undefined Settings properties remain.
Re-skins the entire shell by changing values in one file. All 70 tokens referenced across the desktop tree resolve through Theme, so no call site needed to move for the palette to land. The central change is semantic, not cosmetic. The previous identity mapped "active", "selected", "healthy" and "connected" onto a single green token, which is why green had to mean everything at once. The target separates them: magenta #EB4ADF is attention, green #4FE7A2 is health, blue #4695F6 is an interactive affordance, and red #D1605D is destructive only. acidGreen resolves to magenta rather than green. That inverts what docs/target/colors.md originally claimed, and the file is corrected in the following commit. Checking the 142 call sites showed only about 15 carry a genuine status meaning -- isConnected, isCharging, isScanning, isConnecting, modelData.connected. The remaining 127 are generic accent: borders, corner brackets, hover states, focus rings. Renaming the token to statusGreen would have painted 127 borders green. The gray ramp becomes navy, cool-shifted at every step, so text and surfaces inherit the new identity without touching their call sites. Four tokens were inheriting the wrong source and had to be pinned directly. border, hairline, and ctosGray derived from gray200 and divider from gray500; in the old ramp those were near-white and mid-grey, which put a pale line on a dark surface. They now resolve to #243D70. surfaceSelected was green-tinted (#1A2E24) and is now magenta-tinted, so a selected row reads as selected rather than as healthy. Adds the tokens the target needs before the components that consume them exist: statusGreen, accentBlue, accentViolet, surfaceDeep, borderSubtle, pageBackground, the notch and CCC geometry constants, and the spring parameters from the spec. Also adds radiusLarge and borderWidthAccent. No token name was removed and no call site changed, so this is a value-only change and independently revertable. The one type regression introduced during editing -- secondary demoted from a gray500 alias to an int -- was caught by diffing the declared token sets and fixed before commit. Verified: 0 hard errors under qmllint, all 70 referenced tokens resolve.
The previous commit made acidGreen resolve to magenta, which was correct for 88 of its 127 call sites and wrong for the other 39. Those are the sites that read a genuine runtime state -- isConnected, isCharging, powered, isPlaying, hasMedia, isScanning, isConnecting, modelData.connected, and the negated isOverload and isCritical branches. They now read statusGreen. The distinction matters because it is the whole point of the new palette. A charging battery and a selected workspace are different facts: one is healthy, the other is selected. Under the old palette both were the same green, which is precisely why green had to mean everything. Sites deliberately left on acidGreen, and therefore magenta: workspace focused cell today cell in the calendar grid isSelected network row radial isExpanded / isFocused / isSelected The first two are called out by the spec -- the reference images show the active workspace and the current date as the strongest magenta elements on screen. NetworkTracerWidget's TCP/UDP colouring is categorical rather than statusal, so it stays accent too. Also corrects docs/target/colors.md, which asserted the inverse mapping. It recommended aliasing acidGreen to statusGreen on the reasoning that renaming it would be cleaner; checking the call sites showed that would have turned 88 borders and focus rings green. The document now records the audit, the counts, and the reasoning, so the next reader does not have to repeat it. Verified: 0 hard errors under qmllint.
Joining a network NetworkManager has not seen before has to shell out to
nmcli, because Quickshell's native API can only carry a PSK for a Network
object it already knows. That path passed the secret as an argument:
Quickshell.execDetached(["nmcli", "dev", "wifi", "connect", ssid,
"password", secret])
Any process on the machine could read it from /proc/<pid>/cmdline for the
lifetime of the call, and it would land in any ps snapshot or audit trail.
architecture.md claims the shell does not expose Wi-Fi secrets; this was the
one place it did.
The replacement uses nmcli's own --ask flag, which prompts for a missing psk,
and writes the secret to the process's stdin. Verified against nmcli 1.58.1
on this host: `nmcli --ask device wifi connect <ssid>` with stdin closed
prints "Password (802-11-wireless-security.psk):" and aborts rather than
failing immediately, confirming it reads the secret from stdin. The probe ran
against a real SSID with stdin closed so no connection was possible, and the
profile nmcli created in the process was deleted afterwards.
Two alternatives were evaluated and rejected. `nmcli connection load` with a
0600 keyfile keeps the secret off argv entirely, but it is refused with
"access denied" on this host regardless of whether the keyfile contains a
secret. `passwd-file` is only accepted by `nmcli connection up`, not by the
`device wifi connect` form this code uses. --ask preserves the exact D-Bus
permission path the old code relied on.
The rewrite also fixes three silent-failure paths the execDetached form hid:
rescan now reports a non-zero exit via lastError instead of discarding it
join a failure clears connectingSsid and the connection timeout, which
previously left the UI stuck in "connecting" forever
forget a failure is logged instead of vanishing
None of the three had a stderr handler at all, so a missing nmcli, a polkit
denial, or a bad password were all indistinguishable from success. The open
network path now routes through the same process so it gets the same
reporting.
Output is read from StdioCollector.text in onExited with waitForEnd, matching
the existing BluetoothService pattern. Aliasing Process.stdout does not work
-- it is declared untyped, so the alias resolves to nothing.
Verified: 0 hard errors. The 3 remaining warnings are the QProcess::ExitStatus
enum gap that already affects every onExited handler in the codebase.
Brings the notch's resting appearance in line with the target design. Geometry was wrong in two ways. Expanded width was 380 where the spec says 320, and expandedWidth was declared but had no case in targetWidth, so it was unreachable -- two constants disagreeing, one of them dead. Both now come from Theme.notchWidthCompact / Theme.notchHeightExpanded so the numbers have one home. targetRadius returned Theme.radiusMedium for the hover and calendar states, which drew an 8px-cornered rectangle. The design specifies fully rounded ends at every size, so radius is now height/2 throughout. The existing rounded-rect hit test already took the radius as a parameter, so it follows the shape automatically. The border was a flat Theme.accent that swapped to warningRed on critical notifications. Rectangle.border cannot take a gradient, so the ring is now a separate rounded rectangle behind the surface and the surface is inset by Theme.borderWidthAccent -- a uniform ring, since the inner radius is the outer radius minus the inset. The three stops are the specified blue -> violet -> magenta, degrading to danger red for a critical notification. The surface is the specified near-black navy at 0.94 alpha so the wallpaper reads through. Uses Rectangle.gradient with Gradient rather than LinearGradient; qmllint does not resolve LinearGradient in this import configuration, and the gradient property avoids the type entirely. Verified: 0 hard errors, and LivingNotch warnings are unchanged at 8, all of which predate this change. This is a static check only -- qmllint cannot confirm the pixels, so the gradient and stadium ends still want a look on a live shell before the CCC work builds on top of them.
available was assigned false by each of the three FileView onLoadFailed handlers, and the sampler was gated on `running: root.available`. One transient procfs read error therefore stopped the timer that would have produced the recovery, and the shell reported telemetry offline for the rest of the session with no way back. CpuHexGrid renders "TELEMETRY OFFLINE" and NetworkFlowMatrix short-circuits, so the whole telemetry layer went dark from one blip. available is now derived from a consecutive-failure count with a threshold of three, cleared by the first success. The sampler always runs, because it is the thing that re-probes -- gating it on the flag it maintains is circular. Parsers already return early on empty input, so last-known values are retained across a gap rather than snapping to zero. This also satisfies the requirement in architecture.md that a failed optional integration is logged once with actionable diagnostics. The warning fires on transition to offline rather than on every failed tick, which matters because the sampler retries at 1 Hz. No call site changed: available was already public and read-only in practice, and is now genuinely readonly. Verified: 0 errors, 0 warnings under qmllint.
The shell is ~18k lines of QML and none of it was checked by anything. `nix flake check` covered the Nix side and the package build; CI never parsed a single QML file. Adds checks.qml-lint, which lints all 96 QML files and fails on anything that means "will not compile": a syntax parse error, or a diagnostic qmllint itself escalated to error. Everything else is advisory. The tree carries thousands of style and layout warnings and failing on those would make the gate useless as a regression signal. The gate needed three corrections to get right, each found by testing it rather than trusting it: qmllint exits 0 even on a syntax error, so exit code is not a usable signal. Its --max-warnings is the only thing that goes non-zero, which is the wrong lever for "this file is broken". Diagnostics are parsed from --json instead. Syntax errors are reported as `Warning: ... [syntax]`, not as `Error:`, so grepping for Error matches nothing and the first version of this gate was silently vacuous. It passed a deliberately broken file. Because of that, every earlier lint measurement in this series of changes was weaker than it looked. `grep -c '^Error'` is always zero. Re-measured properly through the JSON report: the tree has 0 parse errors, so the changes are sound, but they were never being verified at the level claimed. Verified in both directions. The clean tree passes; appending a deliberate parse error to Theme.qml fails the build with the file, line, column, category and message, and reverting returns it to green. Type-level diagnostics remain unavailable. QtQuick's qmldir resolves through `linktarget Qt6::qtquick2plugin`, which qmllint cannot satisfy from a Nix sandbox, so every file also carries import-resolution warnings. Adding the Qt6 CMake package files and the qtdeclarative dev output did not resolve it. That limitation is recorded in the check rather than papered over, so the 9468 diagnostics it reports are not mistaken for 9468 problems. Devshell gains qtdeclarative, which provides qml, qmlscene, qmllint and qmltestrunner -- qttools provides none of them. Qt module paths are pinned so the tools cannot silently start, exit 0, and load nothing, which is what happens against the host's Qt. Headless QML testing now works via qmltestrunner under the offscreen platform with the software backend, verified with a passing and a failing assertion. This is how the ~20k-line tests/ harness can run in CI at all: it launches quickshell, which needs a live Wayland compositor, so none of it ever ran. Note qmltestrunner only collects files named tst_*.qml.
The shell is wlr-layer-shell only, so every surface is a layer surface. Qt's offscreen platform cannot present one -- quickshell fails with "No PanelWindow backend loaded" -- and neither can qmltestrunner. That left every visual change unverifiable: the gradient border and stadium ends landed on a static parse check and a note asking a human to look at them. sway with WLR_BACKENDS=headless and the pixman renderer gives a real layer-shell compositor with no GPU, no X server and no input devices. That works over SSH and in CI. quickshell-mcp uses the same approach. shell/tools/ctos-shot.sh boots it, runs the shell, and captures with grim. shell/tools/ctos-pick.py reads the capture back as exact pixel values, so "is that border #EB4ADF" is answerable instead of a matter of opinion. It warns when one colour covers the frame, which is what a failed render looks like. Verified end to end. The first run produced a 1280x800 capture of the real shell: the notch renders as a stadium with the three-stop gradient border running blue to magenta, the active workspace is magenta, and CPU and RAM read green because not-overloaded and not-critical are status, not accent. The whole tree renders without a compositor's worth of services present, which is the degraded-behaviour contract actually being exercised rather than asserted. Three things had to be right and none were obvious: WLR_HEADLESS_OUTPUTS=1 is mandatory -- the headless backend creates no outputs by itself and sway exits with "Could not find config for output". The headless output is not named HEADLESS-1 consistently across wlroots versions, so the sway config matches "output *" instead. QT_QPA_PLATFORM must be wayland. The devshell presets it to offscreen for qmllint and qmltestrunner, which is correct there and fatal here. The devshell gains sway, grim, slurp, wayland-utils, xwayland-satellite, mesa, python3 with Pillow, imagemagick, fontconfig, the Maple Mono faces the theme names, dbus and opencode. Qt modules stay pinned to the shell's Qt. opencode and the render tooling also go into the desktop profile so they survive a rebuild and switch rather than existing only under nix develop. nix-ld needed no change: pixman avoids GL entirely, and the existing nix-ld.libraries already covers the compositor's own deps. The render also exposed two real defects that static analysis had not: CalendarPopup assigns an undefined value to a SystemClock enum, and the RAM and network widgets overflow the output width and clip. Both are recorded and still open.
CalendarPopup assigned SystemClock.Minute, which does not exist, and Qt logged "Unable to assign [undefined] to SystemClock::Enum" on every load. The precision silently stayed at its default. The fix is not the obvious one. Qt documents the enum as SecondPrecision / MinutePrecision / HourPrecision, but a rendered probe on Qt 6.11.2 with Quickshell 0.3.1 shows those all resolve to undefined here, while the plural unsuffixed forms work: Seconds is 3, Minutes is 2. So CalendarPopup now uses SystemClock.Minutes. Three other call sites were already correct and would have been broken by following the documentation instead of the evidence: LivingNotch and the greeter's Time.qml use Seconds, NotchCalendarGrid uses Minutes. All four sites now carry a comment saying the documented names do not work here, because that is exactly the trap. qmllint had flagged this as "Member Minute not found on type SystemClock" and it was dismissed as advisory noise. It was the only correct signal available; the render merely confirmed it. Verified by re-rendering the shell: SystemClock warnings went from 2 to 0.
The palette change reached everything that went through Theme, but three groups of call sites bypassed it and kept the old green-on-grey values. Canvas-drawn widgets. CpuHexGrid and NetworkFlowMatrix draw with Canvas, so ctx.fillStyle and ctx.strokeStyle need CSS strings rather than QML colours, and both files had the old palette hardcoded: #1BFD9C for the healthy stroke, #FC3E38 for overload, #7A7A7A and #C3C3C3 for grey text, #202020 for grid lines. Theme now carries a small set of string-form tokens for exactly this, since the duplication is unavoidable and keeping the strings next to the colours makes the coupling visible. Twelve call sites retokenised. Telemetry panel scrims. Seven widgets set their background to Qt.rgba(14/255, 14/255, 14/255, 0.85) -- the old #0E0E0E -- so the floating panels stayed neutral grey while the notch turned navy. Now Theme.surfaceScrim, which is the current surface colour at the same alpha. App shape defaults. Nine of the ten shapes in components/shapes/ declared property color color: "#1BFD9C". CuratedAppShape always overrides it, so it was dead state, but dead state that carried the pre-rebrand accent and would have shown through on any path that stopped passing a colour. Now Theme.accentMagenta. The greeter keeps its own palette and its own Theme.qml; it is out of scope for this redesign and was left alone. Result: zero occurrences of any pre-rebrand palette hex remain in shell/desktop/. All 46 literals in the tree are inside Theme.qml, which is where they belong. Verified two ways. The parse gate is green over 96 files, and a headless render shows the panels reading navy rather than grey, the CPU hex outlines reading navy, and the network flow baseline in the current status green.
Two defects the headless render made visible. Header overflow. RamBlockBar and NetworkFlowMatrix put a title, a fill-width spacer Item, and a value in a RowLayout inside a fixed 280px widget. A RowLayout gives its children their implicit width, so a long value pushed the row past the panel and the text was cut mid-glyph at the output edge -- "RAM 5.7 / 15.0 G". The spacer was doing nothing because neither text had fillWidth to absorb slack. Replaced the spacer with the arrangement that actually works: the title takes the slack and elides, the value keeps its natural width under a cap. The value is the data, so it is the one that must not truncate; the title yields. First attempt set fillWidth on both texts, which split the row evenly and elided the value too -- the same clipping in nicer clothing. Caught it in the render, not by reading the diff. Binding loops. SkillTree and RadialSegment both fed a computed layout back into its own input: layoutInfo read _lastTargetAngle, and onLayoutInfoChanged wrote _lastTargetAngle. That is a genuine cycle and it fired continuously on every load. The continuity hint is still there, but it now latches on the focused-index change, which is when a segment's target actually moves. The render log is now clean. What remains is expected degradation in a headless environment: no Hyprland socket, no PipeWire, and a notification server already claimed. No binding loops, no SystemClock warnings.
Three changes to LivingNotch, all of which remove a class of bug rather than a single instance. Input. Twelve of the thirteen MouseAreas carried identical hover and wheel handling -- enter, exit, and a volume step -- duplicating a master MouseArea that already did both. Their onEntered was also ungated: it set _isHovered without the isInsidePill test the master applies, so hovering a corner pixel of the rounded pill counted as inside. That is the exact hover-loop class this file needed two separate patches for, and it was still present twelve times. The per-item targets now route clicks and nothing else. The master at z:-1 is the sole hover authority, and it was already correctly gated. Twelve copies of ungated hover logic are gone rather than patched. State. isExpanded was declared as a binding onto _notificationActive and also assigned alongside it in four places, which breaks the binding and leaves two booleans synchronised by hand. An onIsExpandedChanged handler mirrored one into the other, making it a genuine two-way loop. calendarOpen and _isCalendarOpen had the same shape. Each fact now has one writable property -- notificationActive, calendarOpen -- and the old names are readonly aliases kept for the tests. The mirror handler is deleted. The state predicate no longer tests both members of a pair. Content. The idle notch had no logo at all; the spec lists it as the first element with click opening CommandDeck, and it only existed in the hover row. Added, with the same target behaviour. The compact network dot was accent, so a healthy link read as selected. It is a health fact, so it is now statusGreen. Verified against the spec by sampling the render rather than by eye. The border gradient measures #5E78F5 at 30%, #7065F5 at 50% and #D14EE3 at 97%, which is blue -> violet -> magenta blended over the surface. The surface reads #0C132A, and that is the expected result: 6% of the violet ring bleeding through #050E1E at 0.94 gives #0C132B. Not a discrepancy.
The notch's logo was os-icon.svg, a diamond, where the design specifies a hexagon. Replaced with HexMark, a component that draws it. Drawn rather than fetched. A static SVG bakes its colours in, so it could never follow the palette, and the shell has just been through a rebrand -- every icon would have been a second thing to recolour. A hexagon is also the worst case for bitmap scaling because every edge is diagonal, and the notch animates. Drawing it keeps it crisp at any size and routes through Theme like everything else. The gradient outline in the design became a solid accentBlue ring with a magenta core. Two attempts at the gradient are worth recording: ShapePath has no strokeGradient in this Qt, and ShapeLinearGradient does not resolve in this packaging at all, so a gradient outline cannot be declared. The same packaging gap rejects Polygon, so the mark uses Shape/ShapePath, which is what the ten existing app shapes already draw with and what does resolve. At 18px a blue-to-violet sweep is not perceptible, so this costs nothing visible. Wired into both the idle and hover rows, and os-icon.svg is now referenced only by the greeter, which has its own copy and its own theme. Verified by render: a blue hexagonal ring with a magenta core, cleanly antialiased, inside the stadium pill.
The CCC was a PanelWindow of its own in shell.qml, anchored three pixels below the top of the screen. The notch was a different window. That makes the "one surface unfolding" principle impossible: the compositor separates the two, so there is a seam and the notch's border and glow cannot wrap continuously across the join. The CCC is now a child of AmbientBar, directly beneath the pill, and the window grows downward to contain it. commandCenterPopupHost is deleted. Three things had to change with it. Host ownership. Every output has its own AmbientBar, so exactly one of them has to claim the overlay. OverlayController records the screen it was opened on and AmbientBar matches against it by name, so the CCC appears on the output you clicked rather than on all of them. Input region. The mask was a single Region tracking the pill, so clicks on the CCC would have landed on the desktop underneath. It is now two regions -- the pill and the CCC -- because one region spanning the column would make the empty gaps beside both swallow desktop clicks. The mask is declared inline rather than built with Component.createObject. The created-object form could not see commandCenterHost, which is declared later in the file, and it could not stay bound while the host animated. Keyboard focus. AmbientBar took no keyboard input; the CCC's window took it on demand. The bar now does the same, but only while it is hosting, so it does not steal keystrokes when idle. Notch opacity. The notch faded to zero when the CCC opened, which was right when the CCC replaced the bar and wrong now that it hangs beneath it. It stays visible: the design keeps the notch header pinned above the CCC, and that continuity is the point. Fading it left the CCC floating unattached. ctos-shot.sh gains CTOS_IPC so the harness can drive overlay state before capturing. The IPC socket lives under XDG_RUNTIME_DIR, so calling it from outside the script finds nothing -- which is why the first attempt to verify this silently captured the closed state. Verified by render: the pill and the CCC now share one window with no seam and the notch still drawn above. Known gaps, deliberately not addressed here: the CCC is still a single column where the design calls for two, and the pill and the CCC body do not yet share a continuous edge.
The design calls for a two-column panel: notifications, connectivity, bluetooth and media down the left, calendar and session down the right. What we had was a single 360px accordion stack, so the CCC rendered as one narrow column of headers regardless of how wide the output actually was. The six existing sections moved into two sibling Columns inside a Row. The sections themselves are untouched -- each owns its own loaders, lists and inline prompts, and none referenced another, so they relocated intact. Wiring for wifi, bluetooth, audio, notifications and power is unchanged. The panel is now Theme.commandCenterWidth (760) rather than a hardcoded 360. AmbientBar clamps that per output, because a 760px panel does not fit a 1280px screen with much to spare and must not go below a width where the gutter eats the content. Height is content-driven but capped at commandCenterMaxHeight, and the body scrolls past the cap with a plain core Flickable rather than a ScrollBar -- QtQuick.Controls is not imported anywhere in this shell and one scrolling region is not worth a new module dependency. mainLayout is given its width from Theme rather than from the scroller. Sizing it against the animated panel width would re-wrap every paragraph on every frame the panel opened. Three things this surfaced. Row.implicitHeight is derived and read-only, so the two-column height is tracked on root as contentHeight. The parse-level gate cannot catch that one: it reported a clean pass on a file the runtime rejected outright. Type resolution is the gap, not brace matching. Expanded section content is now clamped to 240px. Section content is usually a ListView, which reports the height it was handed rather than the height of its contents. Unclamped, expanding one section handed it the entire panel and left the neighbouring column a stub -- which is what the first two-column render showed, with the notification empty state eating half the height. WindowInterface.mask is a single Region, not a list, and multiple rectangles go in as nested Regions inside it. Building a JS array and assigning it to mask fails with "Cannot assign QJSValue to PendingRegion*", because array elements arrive as QJSValue rather than Region pointers. An inline array literal fails differently, as "[undefined]". The mask is now one Region with two bound sub-regions. That last one also retires the double-buffer flush. The sub-regions bind directly to the notch and the host, so the old maskA/maskB swap -- there only to provoke wlroots into recomputing a region resized in place -- no longer has anything to provoke.
The right column had two collapsed headers in it and several hundred pixels of dead space below them. The design puts a telemetry card there: CPU, memory and disk as rings, with network throughput beside them. Disk needed a new source. SystemMonitorService reads /proc for CPU, memory and network throughput, but procfs cannot answer filesystem capacity -- /proc/diskstats carries I/O counters, not how full a filesystem is. So disk usage shells out to df, on its own 15s timer rather than the 1s sampler that drives the others: capacity moves over minutes, and forking a process every second for a nearly static number is not worth it. The probe skips rather than queues if the previous run is still in flight. Only the filesystem containing $HOME is reported. Separate partitions mounted under / exist, but one ring cannot honestly represent several, and summing across mount points double-counts on btrfs and wherever /home shares the root device. StatRing is drawn on a Canvas rather than with Shape. An arc needs either ShapeLinearGradient or a stack of polygon segments, and neither resolves in this Qt packaging -- QtQuick's linktarget for qtquick2plugin cannot be satisfied, so those types fail at instantiation. Canvas has no such dependency. StatRing declares width and height, not only implicit sizes. Row and Flow size themselves from their children's height, so a StatRing that declared only implicit sizes measured zero tall and collapsed the row it sat in. The card is a plain Item rather than an AccordionSection. The other sections are accordions because they hide input behind disclosure; this one only reports, so there is nothing to collapse and no header action to hang off it. Height is stated directly instead of being measured through a Loader. Verified by render: rings read live telemetry (CPU 5%, MEM 25%, DISK 19%), and 19% matches df exactly for the same filesystem.
The notification section declared a fixed 320px body regardless of whether there was anything in it. With no notifications that reserved a third of the whole panel to render the words "NO NOTIFICATIONS", and in the two-column layout it pushed NETWORK, BLUETOOTH and AUDIO & MEDIA down and left the panel scrolling for no reason. Height now follows the notification count: 128 for the empty state plus the count row and footer, plus about 96 per card, still capped at 320. The section header was already reporting the count, so the body agreeing with it is less surprising than a fixed height ever was. Verified by render: the panel drops from ~620px tall to ~325px with both columns fully visible and nothing scrolled off.
… rebuild Nothing is wired up yet. These are the three pieces the CCC needs to be rebuilt out of, and each one replaces something the existing CCC gets wrong. Card, for AccordionSection. The CCC was built as a stack of disclosure sections, which is the wrong model for a panel whose cards are all meant to be read at once -- and it fought back. Expanded section height had to be clamped, because section content is usually a ListView reporting the height it was handed rather than the height of its contents, so expanding one section handed it the whole panel and collapsed the column beside it to a stub. System Status ended up not using AccordionSection at all. Card states its height directly and has no Loader in the height path. Card also applies the tokens that were written for this and never used: commandCenterSectionRadius, which is why every card in the CCC was drawn at radiusSmall. Square corners were a choice we made by omission. ToggleSwitch, for the header action buttons. Those were text buttons with the width picked by hand -- width: 60 for "[ON]", width: 70 for "[UNMUTE]" -- so changing the label silently broke the layout. This sizes itself and has no text. GlyphIcon, for the icon slots. Those were pointing at CtosIcon, which answers "which app is this?" and maps application names onto brand marks. The CCC was passing it category words -- "timer", "power", "volume", "bluetooth" -- for which it has no entries, which is why those header icons were blank or resolved to whatever matched first. An icon slot wants functional glyphs. Glyph selection is by visible on each Shape, not by loading a Component. The Loader version was tried first and draws correctly when its source comes from a literal, and draws nothing when it is bound to the instance's own glyph property: the binding evaluates once during construction, before the property is assigned, and is never re-evaluated. Direct visible bindings do re-evaluate. Also adds fontFamilySans (Inter), since UI text set in mono is legible but reads as a terminal, which is most of what makes the current panel look like diagnostics output rather than an interface. Verified by render: all twelve glyphs draw, and Card lays out with a rounded tile, an icon tile, a sans title, a disclosure chevron and a working pill.
Replaces the accordion stack with a grid of cards. This is the structural commit: chrome, typography, grid and System Status are in, and the card bodies land in following commits. An accordion is a disclosure control -- it hides its contents behind a click. That is the right model for a list you might not care about and the wrong one for a panel whose cards are all meant to be read at once. The design shows every card open. It also cost us twice: expanded section height had to be clamped because section content is usually a ListView reporting the height it was handed rather than the height of its contents, and System Status had to abandon AccordionSection entirely. The old file was 2620 lines. This is 250, and none of the behaviour is gone -- it was not ported yet. The old implementation is kept outside the tree for the port. What changes visually, all of it from tokens that already existed: Cards are drawn at commandCenterSectionRadius (12) instead of radiusSmall (2). That token was added when the target spec was written and never applied, so the square corners were a choice made by omission. Titles are set in Inter rather than the mono face, drop the "//" prefix, and are dim-then-magenta rather than plain. Labels set in mono are legible but read as a terminal. Header icons come from GlyphIcon instead of CtosIcon, which was being handed category words and has no entries for them. The four header action buttons are real pill switches sized from their content, replacing text buttons whose widths were picked by hand to fit "[ON]" and "[UNMUTE]". Accent is now per card and structural: magenta on Power & Session, blue everywhere else, with green reserved for the CPU ring. Previously the header chrome borrowed status colours, so Bluetooth had a green border and Audio a red one. Preserved from the old version: the focus routing, Escape to close, and the sub-surface routing that expands a specific card when the panel is opened from the power menu or the Wi-Fi rail.
Two changes so the harness can verify service-driven UI instead of only its empty state. The render now always starts a private D-Bus session bus, instead of only when DBUS_SESSION_BUS_ADDRESS is unset. Reusing the caller's bus made the render non-hermetic, and specifically broke notifications. org.freedesktop.Notifications is a single well-known name, so if the caller's desktop already runs a notification server, the headless shell cannot claim the name -- notify-send then delivers to the caller's server and the notification card renders empty no matter what is sent to it. The card looked correct and was unfalsifiable. Network and Bluetooth go through the system bus, so a private session bus does not affect them. CTOS_SHARE_BUS=1 restores inheritance for debugging against the live session on purpose. CTOS_PRE runs a shell command inside the compositor environment just before settling, for driving state that is not reachable over the shell's own IPC -- notify-send, nmcli, bluetoothctl.
List, urgency, dismiss, clear-all and the DND toggle, onto the new Card chrome. Each row carries an app badge, a summary in the sans face, app name and timestamp in mono, the body to two lines, and a dismiss button. The list is capped at 208px and scrolls past that, so a burst of notifications cannot push the other cards off the panel. Urgency colouring changed. The old mapping used acidGreen for normal urgency, and acidGreen is now a magenta alias, so every routine notification rendered as if it were an alert. Normal is structural blue, critical is red, low is dim. The clear-all control only exists while there is something to clear. An always-present button over an empty list is a control that can do nothing. The card body height is stated explicitly rather than read back from the Column that positions it. Deriving it from notifColumn.implicitHeight evaluates once, while the Column's children are still being constructed, and then sticks at zero -- the card rendered with no body at all, and no error anywhere. For a fixed set of three known children, summing their heights is both reliable and clearer than asking a positioner to measure itself. Verified with three real notifications at critical, normal and low urgency.
Four action tiles -- Lock, Logout, Reboot, Poweroff -- routed through SessionService, with Poweroff filled red to mark it as the one that ends the machine rather than the session. Lock runs immediately; it is reversible. The other three replace the tiles with a confirmation row rather than stacking a prompt below them, so asking to power off does not move the question around underneath itself. Escape cancels. Tiles disable themselves while their own command is running, so a second session command cannot be queued behind the first. Card gains an optional subtitle for headline state, which is what the reference puts under "Power & Session": battery percentage and charge state from PowerService. That is a different kind of value from the ones in the body, which is why it belongs in the header rather than in the list. Two bugs worth recording. Card.expanded was being bound from outside while Card also assigns to it on a header click. The first click silently destroyed the binding and the property went back to being whatever it was before. Cards now own their own expanded state, and the sub-surface request assigns to it rather than binding to it. The confirmation prompt read its label with `actions.filter(...)[0]?.label`. QML's JavaScript engine has no optional chaining, and the failure is silent -- the binding evaluates to nothing and the prompt renders with no question in it, just Cancel and Confirm. Replaced with an explicit lookup. Verified by render in both states.
Output and microphone level sliders, mute, and MPRIS transport with artwork, track title, artist and previous/play/next. Adds LevelSlider, because the CCC had these inline twice and the drag handling, fill maths and wheel behaviour all existed in both copies. Margins go in the anchors group rather than on the item -- `rightMargin` alone is not a property, and the failure is a type-resolution error that takes the whole card down. Player selection keeps the old preference order: something playing, then something paused that has a title, then any paused player, then whatever is there. Reading Mpris.players.values is not reactive to a player's own properties, so the trigger counter that forces re-evaluation on track and playback changes comes along too. Album art is clipped by a Rectangle rather than masked: Image has no radius, and an opacity mask for one 40px thumbnail is not worth the render pass. Toasts no longer appear while an overlay is up. A toast is the shell telling you about something you have not seen. With the command centre open the same notification is in the list the user is reading, so the toast is not information -- it is a second window drawn over the panel answering the same question, which is what the comparison render showed. Radial settings and the command deck are covered for the same reason: any of them means the user is mid-task. Notifications are still recorded in history, so opening the panel afterwards still shows them. Overlay open also drops any toast already on screen, which would otherwise sit over the panel for the remainder of its timeout.
Paired and available devices from BluetoothService.deviceModel, with a scan control, and one action per row whose behaviour follows the device's state -- Disconnect when connected, Connect when paired, Pair otherwise. Four controls per row would have been a row of mostly-disabled buttons. Connected devices read green, everything else structural. The card body is hidden entirely while Bluetooth is off rather than showing an empty panel with a switch that is already in the header, and the header subtitle carries the paired count or the connected device's name. Verified against the host's real paired devices rather than a fixture.
Network list with signal strength, the connect/forget flows and the inline password prompt for a secured network with no stored profile. Clicking a network picks the action from its state: connected networks do nothing, known ones connect, and an unknown secured one opens the prompt instead -- there is no key to connect with. Open unknown networks connect directly. That decision is one function rather than a set of buttons whose enabled state has to be kept in sync. Forget is confirmed inline, as it was before. The list is capped at five rows with a "+ N more" note. A dense urban environment listed twenty-odd networks and rendering all of them gave this card the entire column, pushing Bluetooth and Audio out of the panel entirely. Both this card and Bluetooth were clipping their last row. The Column's spacing between its children is not part of any child's height, and the height expressions were not counting it, so the bottom of the last item fell outside the content area. The password field is a core TextInput with its background drawn as a sibling. `background:` is a QtQuick.Controls TextField property, and using it here took the whole card down with "non-existent property" -- this is the second time a Controls property has crept in and taken a card with it. Verified against the host's real networks: the connected one outlined green and first, known ones at full weight, discovered ones dimmed.
Both are unreferenced now. AccordionSection's only consumer was the command centre, and SystemStatusSection was the card that abandoned it.
The design sets every percentage in the colour of its arc, and a neutral grey number beside a coloured arc reads as two unrelated facts. The value now takes the ring's colour by default, with a muted fallback when the sampler is offline so a dead ring reads as dead rather than confidently reporting zero. Captions move above the ring. CPU / Memory / Disk are column headers in the design, with their rings beneath, which also puts each number next to its label. Added fontFamilyMonoNumeric for the values, so a percentage ticking from 9% to 10% does not shift sideways once a second.
The compact notch read "21:06 / IDLE" next to five anonymous dots. It is now the top bar the design draws above the panel. While the command centre is open the notch widens to the panel's width and grows to the expanded height. At the 220px compact width there was nowhere to put a date, three indicators and a collapse control, and the bar is the panel's header while that panel is up. Workspaces are numbered, with the active one a filled magenta circle. Five 6px dots could not say which workspace was which, and at that size a dot has no room to carry a number at all. A divider separates them from the clock. The clock becomes a small date over a large time, which is also the only arrangement that fits both without shrinking the time to caption size. The connected/not-connected dot and the "// IDLE" string become an indicator cluster: glyph over a value, values in the status colour. The old dot said whether the link was up but not how good the link was, and left the rest of the bar's right-hand side empty. Each indicator appears only when its service is available, so there is no permanently dead readout. Adds the collapse control, which the design has and we were missing entirely. DND is kept as text: it is state the user set and needs to be visible. Added fontSizeMicro (9px) for the indicator values. The volume indicator does not appear in the headless render because PipeWire is unavailable there; the card renders it when the sink is.
Clicking more than 280px from the wheel centre closed the whole surface. The wheel's outerRadius is 180, so that dead zone covered most of a 1920px screen and a stray click on the wallpaper or the notch tore down a menu the user had just opened. Distances past the rim are ignored rather than snapped to the nearest segment, since angle-only selection would make a click in the far corner jump the selection to whatever happened to be nearest. leftAnchorX stays at -70, with the reason it looks wrong recorded: outerRadius is 180, so a centre at -70 leaves 110px of a 360px diameter showing, which is the third-of-an-arc the expanded reference draws. Centring the wheel instead puts a large empty circle between the arc and the base node, so the branch stops looking attached to anything.
…xed fan
RadialTopology.qml already held only who is connected to whom; this makes the
renderer agree with that. The layout takes relationships plus parameters and
returns coordinates:
x = parentX + sin(angle) * distance
y = parentY - cos(angle) * distance
Angle 0 points up, +90 right, so rootAngle defaults to 90 -- the expanded branch
grows rightward from a wheel anchored off the left edge, and at 0 it would grow
up out of the ring.
The previous version accumulated an axis down a branch and scaled it by
pow(1 - flatten, depth) per level, which faked the arc a chain makes in two
dimensions by shrinking the whole branch toward the trunk. security needed a
correction on top of that: its fork inherited an axis already rotated -63, so
fanning three children around it threw the recon node to -148, up and to the
LEFT, back over the hub it grows from. That was patched by re-aiming any fork
that left the forward half-plane, which treated the symptom.
Replaced by narrowing the fan per level, which is the actual fix: 170 degrees at
the root, 120 below. A fork deep in a rotated branch simply has less room to
swing, so it cannot wrap behind its parent, and the re-aiming patch is gone.
Offsets are -half + total * i / (n - 1), with a single child getting none -- a
one-child node is a link in a chain, and giving it an offset is what turns
audio's three-deep chain into three stray nodes.
Two things the old version could not do:
nodeRadius is finally used. A fixed fan collapses as children are added: at 120
degrees six children sit 30 apart, which is 72px at a 140px distance, closer
than the nodes are wide. The fan now widens to whatever keeps siblings clear.
Distance holds for two levels then shortens 20% per level, so a tree stops
spreading by depth 4 instead of reaching x = -344.
Both are parameters, and fans is a per-depth list rather than a constant, so
depth 3 is tunable without touching the walk. recentreDeepForks is available and
defaults off: it reproduces a hand-drawn annotation of security where the three
toolkit nodes fan around outward rather than around the branch they hang off,
which reads as a fork forgetting it was on a side branch.
Verified on the live session. appearance reproduces the reference's up/right/down
first level exactly; audio's chain runs straight; security's toolkit fans
-145/-85/-25, all clear of the hub. Min separation 112px against 48px nodes.
Lint 0 fatal across 105 files, nix build clean.
Asked whether adding nodes could push them out of sight, so measured it rather than guessing. At 170/120 a branch that had already rotated once put its children as far as -145, which is left of the root -- and the ring is 180px that way. security's recon node landed 67px toward the ring, the only real category to exceed its root. On a synthetic 3-wide depth-4 tree, which is the shape "add more nodes per section" would produce, the worst node reached 204px: inside the ring itself. 150/100 puts security's furthest node exactly at its root's x and pulls the 40-node worst case back to 156px, clear of the rim. appearance and audio already never reached left of the root at any fan width. Not going narrower than this for now: 120/80 also clears the ring comfortably, but it starts to cost the reference's up/right/down first level, which is the one thing currently matching example-expanded-tree-ref.png exactly. One thing this does NOT fix, worth knowing before more nodes go in: overlap between nodes at different depths. The collision widening only separates siblings at the same depth, so on that 40-node tree min separation falls to 24px against a 48px node whatever the fan is. Spread and overlap are separate problems and only one of them is fan width. Real categories are fine today (95-140px separation), so this is a limit to be aware of rather than one to fix now.
Two changes to the base radial layout. Arms. The rule is now: a fork straddles its own parent's direction by +/- 45, and everything below continues straight along the ray that node already left on. It does not return to the segment axis. Snapping back to the axis collapses the two arms onto each other; forking only at the root, as the previous version did, leaves a second fork in the middle of a chain drawn as a straight line. security shows both ends of that. sec-toolkit inherits sec-lock's -45 ray, so its three children fan around -45 and land on -90, -45 and 0 -- a fork opening off an arm rather than a row of nodes stacked back up the middle. Segment-axis placement also meant offsetting every node from the origin instead of from its parent, which put a chain's tail at the same radius as a sibling's child. Verified in node: security's toolkit now sits at (-135,-79), (-119,-119), (-79,-135) off a trunk running up-left, and audio's three-deep chain is dead straight on one ray. Trunk. The line joining the ring to each tree was effectively not drawn. SkillEdge starts its path one node radius in from each end, and an opaque hub disc covers the inner half of what is left -- so the visible run was the 6px between outerRadius+10 at 220 and r1Point at 241. Forty pixels of dead gap, then 6px of line, which is why the trees looked unattached. SkillTree also carried its own outerRadius of 210 while the wheel's ring is 180, so anything anchored to the rim started 30px outside it. It now takes the wheel's value. Gap 40px -> 6px, visible run 6px -> 99px. Lint 0 fatal across 105 files, nix build clean.
The local layout frame was 90 degrees out from what projectPoint assumes. layoutPreviewTree stepped by (sin dir, -cos dir), so dir 0 meant up. Then projectPoint rotates that by the segment angle, which maps local dir 0 onto (sin a, -cos a). But the outward radial direction of the segment is (cos a, sin a). Those agree only at a = 180, so every tree grew sideways across its own segment rather than away from the wheel -- the top tree went left, the left tree went down, and so on around the ring. Stepping by (cos dir, sin dir) makes dir 0 mean +x, which projectPoint maps onto exactly the outward ray, for every segment. Verified in node against a four-child tree: the centre child now sits at 1.00 radial steps from the root in all four quadrants, the fork children at 0.71 and the grandchild carries out to 1.41. Before the fix the centre child was at 0.00 in all four -- i.e. perpendicular to its own segment, which is the defect. Only SkillTree consumes this, and only through projectPoint, so nothing else observes the frame. layoutTree and the expanded branch are untouched. The doc comment above layoutPreviewTree already claimed +x was "outward along the segment"; the code now does that. Lint 0 fatal across 105 files, nix build clean.
A node's children were measuring their fan from the angle that node itself left on, so a fork was inherited by the whole subtree and the tree spread wider with every generation -- a dendrite. The wheel wants columns. The fork offset decides where a node is placed and nothing else. A child's own children are placed from the axis again, so the two arms below the first fork run parallel instead of curving apart. Verified in node on a root that forks into two arms each two deep: the arms hold y = -39.6 and y = +39.6 at every depth, so the separation stays 79.2px from the first fork to the last node, and every node records dir 0. Under the inherited fork the same tree reached four distinct rows and kept widening. axisDir is now named rather than a bare 0.0, since it is the one angle that survives a fork and the queue is seeded from it. Lint 0 fatal across 105 files, nix build clean.
r1Point 300 -> 242. What that actually draws is the run from outerRadius+6 to r1Point minus node2Radius, since SkillEdge starts one node radius in from each end: 99px of line at 300, 41px at 242. The reference's ring has an outer radius of 119 and its tree roots sit at ~160, so its trunk is 0.35 of the ring radius; 242/180 is 0.34. The comment above this property already claimed 241 and 42 were "the two numbers below" while 300 and 56 sat there instead, so it was documenting values the code did not use. Corrected to describe what is actually there, and the trunk figure now matches it. The step is still 56 against the 42 the comment used to mention -- that one is not changed here. Lint 0 fatal across 105 files, nix build clean.
Hyprland and Niri both had Mod+D bound to the ctOS command deck. Both now spawn
fuzzel instead, which needs adding to the shared package list.
ydotool joins it, with a ydotoold user service -- without a running daemon
`ydotoold` clients have no socket to talk to, so the package alone is inert.
flake/devshell.nix had the input-device switch written as ${CTOS_INPUT: -0}
inside an indented string, which Nix reads as interpolation rather than as the
shell's own parameter expansion. It is now ''${CTOS_INPUT:-0}, so CTOS_INPUT=1
actually reaches WLR_LIBINPUT_NO_DEVICES. That is the flag ctos-shot.sh honours to
let an injected pointer be delivered as a real event.
The rest of the hyprland diff is reindentation from reformatting the file; the
only functional change there is the menu binding.
audio-mic declared minValue/maxValue/stepSize. The slider control reads minVal/maxVal/step -- ContextPanel.qml:402-403, 429-430, 444-448, 480-482 -- and those were the only names it reads anywhere: minValue/maxValue/stepSize appeared in this one node and were read by nothing. So the node silently inherited the defaults minVal 0 / maxVal 100 / step 1, against a gain that AudioService.setMicVolume clamps to [0.0, 1.0]. Both halves of the control were wrong as a result. The bar filled (gain - 0) / (100 - 0), so it read 0-1% even at full gain, and a click anywhere past 1% of the track computed a target near 100 which the clamp turned into 1.0. The step buttons did the same thing: minus always resolved to 0, plus always to 1. Renamed to the contract. The slider now fills 25/50/100% at those three gains, click@25% lands on 0.26 and click@60% on 0.60, and the buttons step by 0.02 clamped at both ends. Also added the root.revision++ that every other slider node has and this one was missing. The track's fill width reads model.revision to establish its dependency because it calls value() imperatively inside the binding, and QML cannot track through a function call -- so without the bump the bar could not repaint. Verified by hosting the real ContextPanel over nodes of this shape at three gains and reading the bars, then deleting the probe. Lint 0 fatal across 105 files, nix build clean. This closes the mic-volume half of Phase 4. The CCC's cardAudio volume sliders were already removed, so this is the only capture gain control in the shell.
Two faults, both in the browser control. The grid was stuck at 328px inside a 972px panel, so it showed two rows however tall the panel got. The cause was the filler Item at the bottom of the root ColumnLayout -- a bare Layout.fillHeight: true, there to pin the key hint. When the browser's control box is also fillHeight, QtQuick.Layouts splits leftover space equally between every fillHeight item, so the control box received about half of it and ~644px went unused. The filler now yields while the browser is active, since only one of the two needs the space. Measured on a 1920x1080 parent with the real ContextPanel and WallpaperService: grid viewport 328 -> 711px, cell 200x132, 2 -> 5 rows, which is 10 wallpapers visible instead of 4. No margin or aspect change was needed; the space was already there. The list itself never refreshed. WallpaperService scans once in Component.onCompleted and again only on onWallpaperDirChanged -- deliberately, since a watcher would hold a descriptor open on a user-supplied path -- so files added or removed while the shell ran were never seen. The grid showed whatever the directory held at boot, indefinitely, which is why deleted images kept appearing. The browser now rescans when it becomes visible: opening it is a deliberate act and costs one `sh`, so that is the moment worth paying for. Verified by adding a file to the scan directory at t=2s and re-selecting the browser node at t=4.2s: count held at 4 (stale) and then read 5 with the new name present. The empty-state text is gated on !scanning, so the rescan does not flash "EMPTY" on the way through. Lint 0 fatal across 105 files, nix build clean.
Three faults, all of which showed up as a chosen wallpaper reverting to another one after a reboot. ctos-wallpaper.service applies .local/share/ctos/wallpapers/wallpaper-v1.png unconditionally at graphical-session.target. It never reads settings.json. The scan handler then applied a wallpaper only inside its fallback branch -- the one that runs when the recorded name is *missing* -- so on a machine whose recorded choice was present and valid the shell applied nothing and the unit won every boot. Desktop on v1, browser showing the real selection. The comment on the session-start block already claimed the shell re-applied the recorded choice and made itself authoritative; it did not. _applyTarget() now applies the recorded choice whenever a scan confirms it is present, remembering the last name handed to awww so a re-scan -- including the new rescan-on-open -- does not re-apply it and fade the desktop on every visit. The fallback itself could destroy the record. A scan is an `sh` process racing the settings file being read, and a scan that finished first saw Settings' built-in default rather than the user's choice. If that default was absent from the directory the fallback wrote the first image over the setting and saved it, so the choice was gone for good, not merely unapplied. Observed directly: a recorded ccc.png came back as aaa.png. The fallback is now deferred until Settings has settled. Settings.isLoaded cannot be the only test, because resetToDefaults sets it back to false -- so it is also false after a failed parse, where neither settings signal fires either. That case is harmless, since a failed parse leaves the default in place and the default is what the unit already applied. The stale-result guard did not work. _scannedDir records where a scan was aimed and is overwritten by the next rescan() while earlier scans are in flight; StdioCollector does not say which process an onStreamFinished belongs to, so an older scan's output passed a check a newer rescan had already satisfied and its files were adopted as the current directory's. This produced a fallback to an image from a directory the service had already left. The scan now prints the directory it describes as its first line and the guard compares against that, so it does not depend on arrival order. Verified against a fixture directory through CTOS_SETTINGS_PATH, recorded choice deliberately not the first entry: the setting survives as ccc.png and _lastApplied is ccc.png with no fallback warning. The legitimate case still fires once -- a recorded file that is genuinely gone falls back to the first entry and records it. Lint 0 fatal across 105 files, nix build clean. The unit still applies v1 at session start, so there is a brief flash of it before the shell applies the recorded choice. Removing that means having the unit read settings.json, which duplicates this logic; left alone deliberately. defaultWallpaper in wallpaper.nix:48 is unused dead code, also left alone.
ctos-wallpaper.service painted wallpaper-v1.png unconditionally at graphical-session.target while WallpaperService applied the recorded choice about a second later. Every boot therefore showed v1 fading out and the real wallpaper fading in, which is the flash that made this visible. Making the shell the authority fixed what ended up on screen but not what was painted first. The unit now reads .wallpaper and .wallpaperDir out of settings.json and resolves them the same way WallpaperService does. Unreadable, absent, keyless, or pointing at a deleted file all fall back to v1 -- each of those is an ordinary condition on a first boot or after a wallpaper has been removed. Verified by evaluating the generated script and running it: against the real settings.json it selects waifu-wallpaper-v3.jpg, against a settings file naming a non-existent image it selects wallpaper-v1.png, and with no settings file at all it selects wallpaper-v1.png. The shell-script escaping is load-bearing and was wrong on the first attempt: a bare `printf ''` inside a Nix indented string is read as an escape sequence, not a shell empty string, and the module fails to evaluate with a syntax error at that character. Replaced with `|| name=""` forms that contain no doubled quote. The module's existing comment already records a related trap in this file, where an interpolated path made Nix parse a PNG as an expression. defaultWallpaper was dead: defined at the top and referenced nowhere, including by the install below it, which is written out longhand. Removed. wallpaperFiles is also unreferenced, but it documents the two-halves contract with WallpaperService.wallpapers and the install is deliberately not generated from it, so it stays. Lint 0 fatal across 105 files, nix build clean.
The grid flicks on wheel and drag already, but nothing said it could. That matters more now that the panel fix took it from two rows to five: overflow is an ordinary state rather than an edge case. Built by hand rather than with QtQuick.Controls' ScrollBar, because this panel deliberately has no Controls dependency -- the same constraint that made the directory field a core TextInput with a sibling background. Track and thumb are sibling Rectangles; the thumb is proportional to the visible fraction with a 24px floor so a long list still leaves something to aim at. Checked the travel arithmetic against the measured grid rather than trusting it: on a 711px viewport with 924px of content the thumb is 547.1px tall and runs from y=0 to y=163.9, landing exactly on the track bottom at maximum scroll with no overscroll. Lint 0 fatal across 105 files, nix build clean.
Deletes NetworkPopup.qml (910 lines) and BluetoothPopup.qml (772), their qmldir entries, the two PanelWindow backdrop/host pairs in shell.qml, the bluetoothVisible/networkVisible and *Screen properties, the four toggle/close helpers, the per-screen liveness checks that kept them on a screen that still existed, and their calls in closeAllPopups. They were not inert. AmbientBar:225-226 emits toggleNetwork/toggleBluetooth from the notch and shell.qml wired those straight to root.toggleNetwork/toggleBluetooth, so the notch indicators opened these popups. Deleting them without rewiring would have left two dead indicators in the notch; this had to land as one change. Both entry points now open the Command Center on the matching card, which is where those controls already live. The mechanism was already in place: OverlayController.openWifiSubmenu/openSystemRailWithSubmenu set pendingRailView, CommandCenter.qml:149-151 turns that into requestedCard, and applyRequestedCard already maps both "wifi" and "bluetooth". Rewired the notch handlers and the two IPC functions ctos-shell-msg calls the same way, so the IPC surface keeps working rather than becoming a no-op. CalendarPopup is untouched -- it is genuinely reachable from the notch clock, and closeAllPopups keeps closing the calendar. Verified by booting the real shell.qml headlessly and scanning the log: zero ReferenceError/TypeError. That check matters because neither qmllint's syntax pass nor nix build would have caught a dangling reference to a property or function removed here; the build only copies files. Lint 0 fatal across 103 files, nix build clean.
- Create modules/features/desktop/gtk.nix with GTK3/GTK4 config, Yaru theme, Nautilus file manager - Update xdg.nix to replace KDE/Dolphin with GNOME/Nautilus for mime apps - Enable desktop profile on Makima host - Set GTK_USE_PORTAL to prefer GTK file chooser
- Replace hyprpolkitagent with polkit_gnome in desktopPackages.nix - Remove KDE xdg-desktop-portal-kde from xdg.nix - Simplify portal config to use only GTK backend
- gtk.nix: thunar (GTK3, fully themeable) + superfile (TUI) - hyprland.nix: Mod+E opens superfile in kitty - niri.nix: Mod+E opens superfile in kitty - workstation.nix: removed yazi feature - Thunar uses Yaru GTK3 theme completely (no libadwaita headerbar issue)
- desktopPackages.nix: restore hyprpolkitagent, kdePackages.dolphin, kdePackages.dolphin-plugins, kdePackages.okular - xdg.nix: restore KDE MIME handlers (dolphin, gwenview, okular, ark) and KDE portal priority - Keep superfile as TUI file manager (replaced yazi)
…and add polkit gnome
- Added "ethernet" glyph (U+E328 router) to GlyphIcon.qml - LivingNotch: network indicator now shows wifi when on WiFi, ethernet (router) glyph when on wired. Uses NetworkService.isWifi / isEthernet. - Detail text shows signal% on WiFi, "LAN" on Ethernet. - Volume glyph opticalScale: 0.85 -> 0.90 (matches wifi scale) - Battery glyph opticalScale: 0.73 -> 0.85 (matches volume scale) - GlyphIcon: added "ethernet" mapping to U+E328 (router) Verified: lint 0 fatal / 103 files, nix build clean.
- CHANGELOG.md: comprehensive log of all bug fixes and changes today - decision-log.md: added BF-001 through BF-012 for all bug fix decisions - delivery-tracker.md: added Phase W (Wallpaper Authority), Phase N (Notch Refinements), Phase P (Popup Retirement), Phase M (Mic Volume Fix) with all items marked done All gates pass.
- C2 (flushWaylandMask for CCC) dropped: CCC lives on overlay layer; mask would swallow all mouse input. You were right. - Phase B updated: B1/B2 done, B3/B4 partial — tracker was stale. The CCC (C1, C3-C7) is already implemented. Only C8 (disk telemetry) and C9 remain. Phase B is mostly done; only visual polish (glow, reducedMotion edge cases, state machine hardening) remains.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.