Commit Graph
18 Commits
Author SHA1 Message Date
Gabriel Brown 36fdd4e076 Give the VPN a toggle, an indicator, and a way back
Turning on a WireGuard profile whose server was unreachable used to cost the
whole network stack, and the only way out was nmcli typed into a terminal.
Quickshell's Networking module has no VPN surface, so this arrives as the one
sanctioned nmcli exception: a helper that lists, raises and lowers profiles,
a service that watches NetworkManager for changes made anywhere, a quick
settings tile (left-click toggles the most recently used profile, right-click
picks among them), and a bar glyph while a tunnel is up.

The safety property is in the helper, where it cannot be skipped: activation
waits a bounded 25 seconds, and a failure is rolled back down and reported
instead of leaving a black-hole default route. The contract pins exactly that,
against a stateful stub NetworkManager.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
Gabriel Brown 23141673a2 Hang the Control Center where every other popover hangs
Five surfaces sat 12 pixels under the bar -- the date menu, the activity panel,
notification toasts, signal glass and the clipboard, all using barGap * 2. The
Control Center sat at 2, through a constant of its own, which left the widest
surface in the shell hanging ten pixels higher than the date menu beside it.

That constant arrived with the original Control Center and carried no reason,
while barGap directly above it explains itself. The clipboard even cites
QuickSettings in a comment for how it derived its own margin, and still landed
on 12. It reads as an early value nothing else converged on rather than a
decision, which is why it is going rather than being documented and kept.

The contract that guarded it pinned the literal, and that same file already
records where pinning a literal led: it once asserted the buggy margin
expression, so the code and the test agreed and a 38-pixel gap was invisible to
both. Replacing one number with another would have repeated it. It now reads the
top margin out of the Control Center and out of the date menu and requires them
to match, so drift in either direction fails -- verified by moving each one in
turn and watching it break.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:41:12 -04:00
Gabriel Brown 3b657e0a56 Make the resize keys do what the fingers expect
Audited against the real Forge configuration rather than its defaults: the
bindings are still in dconf, and the extension is still installed, so both the
keys and the code behind them could be read directly.

The port was faithful. Forge's resize() grows the window for a positive amount
in every direction -- the edge only decides which side moves -- so
"increase" meant grow and "decrease" meant shrink, and Y/O/I/U mapped to Wider
and Taller exactly as they should have.

What did not survive is direction. Forge resized one named EDGE: Y pulled the
left edge leftward, O pushed the right edge rightward, and the window opened
from the side you asked for. Hyprland resizes along an axis and lets the layout
pick the border, so eight distinct behaviours collapse onto four and the
direction is not expressible at all in dwindle. Correct on paper, wrong under
the hands: the keys that used to pull a window open from one side now push it
from the other, which reads as the pair being swapped.

So the sizes are inverted from Forge's naming on purpose. Y and O shrink, B and
M grow, I and U shorten, N and Comma lengthen. Faithfulness to a mapping nobody
can feel is worth less than keys that behave the way their owner reaches for
them, and the reason is written where the next person will find it.

Also fixes the Home Assistant accessories dropdown, which looked glitchy for a
reason that was not performance. The resting card hid the instant the section
opened -- a Column skips invisible children, so everything above snapped up
while the expanded grid was still sliding open underneath. It now collapses on
the same curve and duration as the section replacing it. This was the only
control with a resting and an expanded form that swap, which is why it was the
only one that looked wrong.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:10:17 -04:00
Gabriel Brown c148bae4ac Let Ethernet be switched off and on
Wi-Fi had a switch and wired did not, which left no way to take the cable down
without reaching for nmcli -- and this machine has two interfaces on one subnet,
so turning one off is a genuinely useful thing to be able to do.

Both halves are needed to turn it off. Disconnecting alone survives the session
but not a carrier event or a reboot, because NetworkManager brings an
autoconnecting device straight back; the switch sets the device's autoconnect
alongside it. The device property rather than the connection profile, so a
toggle here does not quietly rewrite a saved connection somebody expects to come
up at boot.

The first version built a trap door. It decided whether the switch was usable
from hasLink, which reads false while a device is merely disconnected even
though NetworkManager still reports the carrier as on -- so turning Ethernet off
made the switch disable itself, blame the cable, and offer no way back. A wired
device that exists can always be asked to come up; if there is really no cable
the attempt fails and says so, which is the honest failure. The word "off" is
used where the old text guessed "no cable", because nothing available here can
tell those apart.

Verified as a round trip against the real device, including that off stays off
through eight seconds rather than reconnecting a moment later, and that the
mechanism tested is the one the code uses -- the first test drove the connection
profile while the code drives the device, which are different things.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 12:13:49 -04:00
Gabriel Brown a23b42841a Own user accounts and sharing
Two of the panels this desktop still handed to GNOME Settings.

Users manages the account through accountsservice -- the same daemon
GNOME's panel drives, so a name or picture set here is what the login
screen and lock screen read. Name, picture, account type, password,
automatic login, and adding or removing other accounts. Every change is
authorized by polkit through the agent this session already runs; a
dismissed prompt is a normal outcome and says so.

A new password is read from the helper's stdin, hashed by openssl
reading its own stdin, and handed over D-Bus from inside that process.
It is never an argument: argv is world-readable through /proc, so a
password passed that way is published to every process on the machine.
Removing an account takes two presses and says it destroys their files;
the last administrator cannot be removed or demoted, because a machine
nobody can administer is not a state to offer.

Sharing reports what is actually true, including "the software for this
is not installed" -- the honest answer for Samba here, and the case the
panel it replaces shows as a switch that does nothing. Password sign-in
is reported from sshd's configuration rather than assumed: claiming
"keys only" when the file is silent would state a security property that
cannot be backed up.

The Control Center now draws the account's real picture and name. A
generic glyph sat there while a real avatar was already set, which made
the desktop look like it did not know whose it was.

Also here: the KDE Connect contract no longer requires a phone to be
awake. kdeconnectd drops its device objects for a phone it has not seen
recently while the pairing survives in its config, so demanding one
failed whenever the phone was off.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 13:05:13 -04:00
Gabriel Brown eeb49c5aff Hang the control center under the bar instead of below it
The panel opened 38 pixels beneath the bar while its own comment said
two. margins.top added Theme.barHeight to the gap, but an exclusiveZone
of 0 means "reserve nothing, respect what others reserved" -- so the
surface already began below the bar's 36px zone and the bar height was
counted twice.

The margin is now the gap alone, which also means the panel follows the
bar: if the bar ever stops reserving space, this closes up against the
top edge rather than hanging under nothing.

Measured before and after against hyprctl layers: bar bottom at y=36,
panel top from y=74 to y=38.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:23:05 -04:00
Gabriel Brown d96863b687 Convert British spellings to American across the repo
colour -> color, behaviour -> behavior, centre -> center, favourite ->
favorite, and about twenty other pairs, applied consistently across
comments, docs, error/UI copy, and a handful of QML identifiers that
used the British spelling as their actual name: SystemSettings'
serialiseValue/serialiseTable/normaliseGradient, Displays'
normaliseModes, Wallpaper's normalisePolicy, SettingsBackup's
serialiseHomeState, DateTime's ntpSynchronised property, Clipboard's
_normalise helper, and ShortcutCapture's cancelled signal (with its
onCancelled handler in ShortcutsPage.qml). Every call site and the two
tests that assert on the literal source text (settings-ownership and
settings-backup-live contracts) were updated in lockstep.

Left untouched: config/dot/espanso/match/packages/misspell-en/ is a
vendored third-party autocorrect dictionary -- its entries are typo
corrections, not our prose, and rewriting them would fight the
package's own purpose (and any future re-sync from upstream).

The already-American `favorites` property (Home page pinned
accessories) was never actually misspelled -- only nearby comments and
error strings said "favourites" -- so no data migration was needed
there.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-19 08:07:55 -04:00
Gabriel Brown e6b4d3c1a1 Stop lists and sliders from losing input under the user
WifiList and the notification toasts built their model from a plain
computed array, so any background property tick (a scan result, an
unrelated notification arriving) reassigned the whole array and the
Repeater destroyed and recreated every delegate -- including one with
an open, focused password field or an in-progress reply. Switched
both to a ScriptModel, which diffs by identity instead of resetting.

ValueSlider had its pointer-to-value mapping offset by 16px (the
hit-area margin was applied with the wrong sign), so 0% was
unreachable and every click landed to the right of where it was
placed -- affects every slider in the shell. SliderRow used -1 as a
sentinel for "nothing pending," which collides with legitimate
negative preference values like pointer sensitivity.

Dock intellihide read the globally focused workspace instead of each
monitor's own, so an empty workspace on one screen could hide the
dock on another; ActivityPanel rebuilt every row once a second during
a recording because the elapsed-time read lived in the model
construction instead of each row's own binding.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:58 -04:00
Gabriel Brown bd5d030d91 Put power profiles and the colour scheme in the Control Center
The panel is for "change it now" decisions, and two of the most obvious
were reachable only through Settings. Night Light was already here;
these were the genuine gaps.

The colour scheme is a grid toggle beside it. It writes the preference
and stops -- ColorScheme propagates the change to GTK, the portal, the
terminals, the launcher, btop, tmux and the lock screen, and nothing in
the panel needs to know that list. Light is the lit state because dark
is what Panama ships, and "active" reads as the non-default everywhere
else in this grid.

Power profiles are a summary row that expands into a list, matching how
the audio device lists behave. A row per profile rather than a cycling
button: there are three, and cycling passes through one you did not want
on a machine where the change is immediate and audible. The row hides
entirely where no power-profiles daemon is running, and the panel
re-reads the active profile on open, because the daemon owns it and
anything on the system can change it.

Worth recording, because it invalidates something I believed earlier in
this session: the live shell runs `quickshell --daemonize` and does NOT
hot-reload. Editing a file under config/dot/quickshell changes nothing
until the shell is restarted. The PID changes I had taken for hot
reloads were tests killing and restarting it. Both of these controls
were written, verified in a harness, and completely absent from the
running panel until the shell was restarted.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 16:25:59 -04:00
Gabriel Brown 7541a45e77 Add external monitor brightness over DDC/CI
brightnessctl drives the kernel backlight class, which laptop panels
have and this desktop does not -- it reports only keyboard and NIC LEDs.
So BrightnessControl removed itself and there was no way to dim the
screen from Panama at all. DDC/CI is the channel the buttons on a
monitor's bezel drive, and it is the only brightness an external display
has. Both sources now render a row each, so a machine gets whichever it
actually has, or none.

Displays are enumerated from sysfs rather than `ddcutil detect`. The
kernel publishes the connector-to-bus mapping as
/sys/class/drm/<card>-<connector>/ddc along with whether anything is
plugged in, which beats parsing detect's undocumented brief output,
yields the connector name spelled exactly as Hyprland spells it, and
probes only connectors with a monitor attached -- one bus on this
machine rather than fourteen, where each empty bus costs a timeout.
No model name is read: Hyprland already knows what every output is
called, so the UI joins on the connector instead of keeping a second
source of truth that could disagree with the Displays page.

Writes are debounced, serial, and read back. Serial because DDC/CI has
no arbitration and two ddcutil processes on one bus interleave their
exchanges and both return garbage. Read back because a write is not a
promise: panels clamp to their own range, ignore values while waking
from standby, and drop writes that arrive too fast. Without the read the
slider would show what Panama asked for rather than what the monitor
did, which is the same class of lie as trusting `hyprctl keyword`.

Brightness is deliberately not a stored preference. The monitor
remembers it and the bezel buttons change it behind Panama's back, so
persisting it would mean restoring a value the panel had moved past.

The contract runs against fixtures with ddcutil stubbed and both sysfs
roots redirected, so it never touches a real monitor. Its fixture
reports a maximum of 200 rather than 100 on purpose -- at 100 the
scaling arithmetic is the identity and a helper that ignored the
reported maximum would pass everything. Verified it catches that, plus a
dropped connection-status filter and an unstripped connector prefix.

Not yet confirmed against hardware: this machine cannot open any I2C bus
yet. ddcutil's udev rule grants that through uaccess but only to devices
created after it was installed, so it needs one udevadm trigger. The
helper detects exactly that case and returns the command as its error
rather than reporting "no displays".

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 08:01:49 -04:00
Gabriel Brown 3cfa592db9 Build complete PipeWire sound settings 2026-08-18 05:54:14 -04:00
Gabriel Brown 7f540a58e5 Avoid visual Phone harness teardown 2026-08-17 19:42:15 -04:00
Gabriel Brown 3213a0989a Fix Phone controls runtime accessibility 2026-08-17 19:01:42 -04:00
Gabriel Brown fbf729b25c Add BlueBubbles to Phone controls 2026-08-17 18:48:28 -04:00
Gabriel Brown e37df9d838 Fix Home shelf slider interactions 2026-08-17 18:34:48 -04:00
Gabriel Brown 673aac6aba Build the Home accessory shelf 2026-08-17 18:22:27 -04:00
Gabriel Brown 5940d6b4bd Build the Panama Control Center 2026-08-17 13:44:18 -04:00
Gabriel Brown 5248883e4b Build the Panama Hyprland desktop 2026-08-17 10:32:55 -04:00