Skip to content

Feat/neon island - #10

Merged
reze-dev merged 129 commits into
mainfrom
feat/neon-island
Oct 8, 2026
Merged

reze-dev merged 129 commits into
mainfrom
feat/neon-island

Conversation

@reze-dev

@reze-dev reze-dev commented Oct 8, 2026

Copy link
Copy Markdown
Owner

No description provided.

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)
- 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.
@reze-dev
reze-dev merged commit 34348fa into main Oct 8, 2026
1 check passed
@reze-dev
reze-dev deleted the feat/neon-island branch October 8, 2026 20:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant