Commit Graph
61 Commits
Author SHA1 Message Date
Gabriel Brown f8f5b25510 Make Shell a category, the bar legible, and the dock a real dock
Desktop & Dock becomes Shell — Bar, Dock, Control Center, Tiling,
Workspaces — the home for everything Quickshell draws. The settings-
management cluster moves to System as Sync & Backup, Appearance's
Shell tab dissolves, and 24-hour time finally lives on Date & Time,
which always owned it.

The bar gets what it never had: a way to survive the wallpaper. A
second neutral text family (follow theme, or forced light or dark),
a one-layer shadow under every glyph, and a gradient scrim for
wallpapers nothing else survives — all off by default, pixel-identical
until asked. Widgets earn toggles (weather, media, clipboard, calendar
countdown), the vitals cluster stops leaving a dead pill behind, and
Control Center's sections learn to step aside.

The dock graduates from MVP: a context menu with window rows, pin,
unpin, quit and new-window; scroll an icon to cycle its windows; drag
to reorder on the dock itself; hover previews with one-shot captures;
and "Add App to Dock" in the launcher. Three real bugs died en route —
menus that slid away with the autohide, a readonly-property crash on
every menu open, and a drag that drifted half a slot per icon on side
docks. The pinned-apps editor in Settings becomes a drag strip.

166 contracts; the full suite is green except two live display and
switcher tests that cannot run behind a locked session — re-verified
on unlock.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 04:28:20 -04:00
Gabriel Brown 5490fd285d Fold thirty-one settings pages into fifteen categories with tabs
The sidebar was a flat scan of thirty-one rows; now it reads like a
settings app. Multi-subject categories (Input, Network & Sharing,
Applications, Users & Accounts, Privacy & Security, System) carry an
Appearance-style tab strip above the page, drawn by the shell so the
leaf pages themselves are untouched. The taxonomy lives in one new
file, services/SettingsRoutes.qml; the sidebar, the strip, route
validation, search breadcrumbs, and both generators derive from it.

ShellState.settingsPage still holds leaf ids, so every deep link, IPC
call, and search result keeps working — and now lands on the exact
tab. Dictation moves out of Sound onto its own page under Input, with
a handoff back to Sound for the microphone. The strip scrolls when
System's nine tabs outgrow a tiled window. All 161 contracts pass.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 20:21:31 -04:00
Gabriel Brown e723fabed3 Stop the session you started, so the next one can begin
Logging out of plain Hyprland left the desktop running. autostart.lua starts
about ten user units, none of them children of the compositor, and the shutdown
handler that was meant to stop them stopped hyprland-session.target -- a unit
Fedora does not ship. systemctl says 'Unit not found', the handler reports
nothing, and hyprpaper, hypridle, vicinae and the polkit agent keep running
after logout. Each of them Requires=graphical-session.target, so that target
stayed active with no session behind it.

The symptom was not a broken Hyprland. Both uwsm and gnome-session refuse to
start into a session that is already running, so 'Hyprland (uwsm-managed)' and
GNOME both bounced back to the login screen, and plain Hyprland -- which makes
no such check -- was the only one that worked. The session that looked healthy
was the one leaving the mess, and it was the one Panama tells people not to use.

Now stops graphical-session.target, which every one of those units is PartOf,
so a unit added to the start handler later cannot be forgotten here.

panama-crash-watch had the same leak and no PartOf at all: a journal follower
per login, and a 'once per program per session' dedup that had quietly become
once per boot.
2026-08-23 13:55:47 -04:00
Gabriel Brown 31668619a4 A shell that comes back, a recording you can hear, a layout you can see
Quickshell segfaulted mid-session -- a Qt image-teardown bug, three cores in
the journal -- and the desktop stayed bar-less until a person noticed and
knew what to type, because the shell ran as a bare compositor child and
panama-crash-watch's report had no notification server left to arrive on.
The shell is now panama-quickshell.service, started per-session by
autostart.lua like every other Panama unit and never enabled globally:
Restart=on-failure turns the same crash into a two-second flicker, verified
by sending the running shell a real SIGSEGV and watching it return, and the
crash report now lands because the restarted shell is serving the bus by the
time the watcher looks. The two contracts that restart the shell learned to
do it through the unit, or the unit's own restart races them with a second
shell.

Recordings can hear: a recorderAudio preference -- none by default, GNOME's
default too, because a screencast that silently captured the microphone is an
incident -- adds system audio or the microphone through PulseAudio's
@DEFAULT_*@ aliases, so the capture follows whatever device Sound settings
has chosen.

The bar shows the active keyboard layout whenever more than one is
configured, mapped from xkb's own registry (evdev.lst) because deriving a
code from a description guesses wrong immediately -- "German" is de, not ge
-- and updated live from Hyprland's activelayout event. One layout, no
indicator, which is GNOME's behavior too.

And presentation mode: Caffeine plus Do Not Disturb as one quick-settings
tile, restoring both exactly as found -- the half you forget to arm before
plugging into a projector is the one that fires a message preview onto the
big screen.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 12:41:36 -04:00
Gabriel Brown 9530e32326 Give a laptop its first week: the keys, the lid, the battery's endgame
The audit's fourth tier -- what a Framework owner reaches for in the first
week and found missing.

The power button stops being an instant, unconfirmed poweroff: a shipped
logind drop-in tells the daemon to stand down and the compositor binds the
key to the power menu, the way GNOME turns it into a question. Holding it
still hard-cuts through firmware. change-settings restarts logind so the
change applies without waiting for a boot, and the Power page says what the
button does now.

The function row fills in: F10 (XF86RFKill) toggles airplane mode through a
new panama-osd verb that blocks or unblocks every radio and says which way it
went; F9 (XF86Display) opens the Displays page, the honest action until
mirroring exists. And the lid becomes a switch bind: closing a docked lid
turns the internal panel off so nothing renders inside a closed shell and no
workspace strands on an invisible output, and opening it restores the panel
with the mode and scale chosen in Settings. panama-lid owns both decisions;
undocked machines suspend via logind before any of it matters.

The battery gets an endgame. On battery the screen dims to 30% two-thirds of
the way to blanking -- GNOME's single largest idle battery saver -- and
restores exactly the level it saved. At the urgent threshold the machine
suspends after a fifteen-second grace, cancelled by plugging in, because a
suspend preserves the session for days and a hard cut at 0% preserves
nothing; "Only warn" remains a choice on the Power page. Hibernate joins the
power menu, but only where logind answers CanHibernate with yes -- an entry
that fails silently is worse than none.

And brightness stops being two code paths: the Displays page now embeds the
same control the quick-settings panel uses, so the built-in backlight and
DDC/CI monitors share one surface that withdraws itself where neither
exists. The lid contract narrows to what its principle protects -- a
HandleLidSwitch drop-in -- so deliberate policy for other keys can ship.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 12:21:08 -04:00
Gabriel Brown 44124d72fa Take one machine's fingerprints off everyone's desktop
The audit's second tier: values that were measurements of the author's
desktop, shipped to every machine as if they were defaults.

Settings greeted every human as Gabriel; it now greets whoever
accountsservice says is signed in, and nobody when it says nothing. The
weather shipped his home coordinates and confidently reported his forecast
anywhere on earth; it now ships unset, fetches nothing until a location is
chosen, and the location row says so. The GTK bookmarks carried seven
/home/gib paths and his file server into every file dialog; they are now
generated per machine from a template and gitignored -- Nautilus edits the
instance freely, the way settings.ini already worked one file over. Web
search routed through his personal bang redirector; the engine is now the
webSearchUrl preference with a DuckDuckGo default, read by both the script
command and the suggestions extension, which the launcher-search contract
already pins to one another. The GPU vitals path defaulted to his card1 and
lost the readout on any machine enumerated differently; a machine with
exactly one GPU now adopts it. And the Containers and Snapshots pages hide
once a scan proves their backing stack absent, instead of rendering
permanently empty on machines that never had podman or snapper.

Lesser residue swept in the same pass: the DP-2 hyprpaper block one machine
needed, the author's username-typo expansions (moved to his personal seed in
user/, where personal content belongs), a capture fallback into /home/gib,
and a parity table asserting one machine's hardware as fact.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:55:43 -04:00
Gabriel Brown 3d21e20041 Make every control tell the truth
The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.

Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.

The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.

The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.

The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.

Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:43:39 -04:00
Gabriel Brown 7a5e990439 Let somebody extend this without forking it, and say when things die
Two of Section F.

Hooks are the pressure valve. "Can Panama also do X when the theme
changes" is now a five-line file in ~/.config/panama/hooks rather than
a fork, a feature request, or a patch somebody rebases forever. Each
name takes a single file and a .d directory so several things can react
without fighting over one, and a broken hook is reported and stepped
over: somebody's script must never cost a theme change, an upgrade or a
login. Wired at theme-set, post-upgrade and post-migrate. This is the
thirty-line version of the plugin host the upstream ledger defers, and
it has no API to keep stable beyond "we will run your script and tell
you what happened".

Testing it caught a real bug the reading would not have: run_one
captured the script path but never shifted it off, so every hook got
its own filename as $1 and the real arguments arrived one place late. A
hook reading $1 as the colour scheme got a path.

The crash watcher notices when a program dumps core and says so. Under
GNOME, ABRT does this; here nothing did, and applications died silently,
which is most of how "Linux is flaky" gets earned.

Once per program per session is the entire design, not a nicety. This
machine's portal backend crashes between eleven and sixty times a day,
and a notification per crash would be one every few minutes for
something nobody can act on. The first is news; the fortieth is why
people turn notifications off. The health page keeps the running count.

It waits for the notification server before reporting, because the
crash most worth hearing about is the one that took the shell with it,
and it names the executable rather than the kernel's comm field, which
truncates at fifteen characters. Verified against real segfaults.
2026-08-22 08:11:49 -04:00
Gabriel Brown 41dd91eb75 Write down where all of this came from
Section G, the closeout.

UPSTREAM-INSPIRATION gains the second and largest borrowing round: what
was taken from omarchy, end-4, caelestia, ML4W and HyDE, what was
rebuilt rather than transplanted, and -- the part that matters more --
what was declined and why. Wallpaper-derived colour is declined on
identity, because dynamic colour is how rices end up looking like each
other. Audio visualisers are declined because a continuously repainting
equaliser is the GPU-pegging animation this project avoids. Patching
application internals to theme them is declined because the maintenance
is unbounded and lands on whoever is holding the desktop that day.

DESKTOP-PARITY gains a Portable machines section, which it has never
had, because until yesterday Panama was a desktop configuration in the
code as well as on the machine. Nine rows, one of them honest about not
having run on hardware with a lid. The remaining-gaps list gains the
four things now known to be missing rather than the one.

The README stops describing a desktop from before yesterday: migrations
in the install flow, the welcome and cheatsheet and manual, and laptops
working with every surface hiding itself where the hardware is absent.

The plan records its own outcome per section, including the two places
the work departed from it: D2's template renderer deferred once D1
removed the duplication it existed to fix, and E1 inverted after using
it for an afternoon.
2026-08-22 07:48:24 -04:00
Gabriel Brown 8d66247b7c Open a new one with SUPER, go to the old one with SUPER+ALT
Making the plain application keys focus an existing window was the
wrong call. It reads well in a demo and it is what macOS does, but it
made "give me another terminal" the awkward case -- and on a tiling
desktop a second terminal beside the first is the normal way to work,
not an edge case. Reaching for the launcher to open a second file
manager is not an improvement on anything.

So the plain keys do what they always did, and SUPER+ALT is the new
capability rather than a tax on the old one: go to the terminal,
editor, browser, files, calculator or mail you already have, wherever
it is, and start one only if there is none.

ALT rather than SHIFT because SUPER+SHIFT is already the
window-manipulation space -- Files, Neovim and Settings would have
collided with Focus session, Taller and Shorter, and breaking two keys
out of the eight-key resize set to make room is the worse trade.

Also fixes a real trap found while using it. The Alt-Tab overlay
commits on SUPER release, which is a compositor bind running an IPC
call; if that call ever fails to land, the overlay stayed up with no
keyboard focus, no Escape handler and nothing clickable, so the only
way out was an IPC call typed into a terminal it was covering. Clicking
outside now dismisses it, clicking a row switches to that window --
which is the obvious thing to try and did nothing -- and an abandoned
switch closes itself after ten seconds. Keyboard focus still stays with
the compositor, because taking it mid-switch is what would break
stepping.
2026-08-22 07:22:09 -04:00
Gabriel Brown 333ab9f6b7 Press the browser key twice, get one browser
The application keys ran their command unconditionally, so pressing the
browser key twice gave you two browsers. That is the single most common
"Linux feels wrong" moment and it is a twenty-line fix: match the
window class, focus it if it is there, launch if it is not.

Two details the obvious version gets wrong. Patterns are anchored,
because an unanchored "mail" matches gmail-notifier and the mail key
would raise somebody's notifier. And class alone is not enough here:
the terminal and the editor are both kitty, so the editor bind narrows
by title or it raises whatever terminal happened to be open. The
contract's fixture contains both traps.

Found and fixed a bug this shipped with earlier today. Hyprland 0.56
dispatches through Lua, so `hyprctl dispatch focuswindow address:0x...`
is parsed as Lua source and fails with a syntax error -- which is what
the launcher's Switch Window command had been doing since it landed.
Its own contract stubbed hyprctl and recorded the arguments, so the
call looked correct and never ran. Both now use the selector form
hl.focus actually accepts, and the contract asserts the shape rather
than only that something was dispatched.
2026-08-22 06:02:47 -04:00
Gabriel Brown e1a04d2d70 Write the eight accents down once
They were written down five times: ThemeProfileModel.js for QML,
looks.lua for the compositor, and again in panama-theme-apps and
panama-lock. The GNOME accent-name mapping was a sixth list. Adding a
ninth accent meant editing all of them, and the file most likely to be
missed was the lock screen, which fails silently -- the machine locks
in last season's colour and nothing says why. panama-theme-apps
admitted it in a comment: "there is no shared source between QML and a
shell script".

config/palette.json is that source now. looks.lua reads it through a
new prefs.readJson, which uses the same never-raise parser the settings
store uses, so an unreadable palette costs the accent colours and never
the compositor config. The two shell generators read it through
scripts/panama-palette, which also carries the hex-to-rgb conversion
hyprlock needs and the GNOME member lookup.

QML keeps its table, because a .js module imported into QML cannot read
a file. That is still a copy, so the palette contract compares the two
value by value -- every accent, every field -- and fails on any
disagreement. Verified by planting a wrong hex and watching it name the
exact field.

The adwaita contract used to check the shell's own copy of the GNOME
mapping. It now checks that the shell resolves through the palette, and
fails if that copy ever grows back.
2026-08-22 05:42:53 -04:00
Gabriel Brown 9fbbdd902b Answer "what can I press" in one keypress
The Shortcuts settings page answers "how do I change this", which is
worth opening a window for. This answers the other question, the one
you have with your hands already on the keyboard, so it is an overlay
on SUPER + / and the same key closes it.

It reads Keybinds.grouped() rather than a written-down list, so a
shortcut rebound in Settings shows its new chord here with nothing kept
in sync. A cheatsheet that lies is worse than none: it gets consulted
exactly when somebody does not already know.

Three columns, balanced by how many shortcuts each category holds. The
first attempt used a Flow, which wraps into as many columns as it likes
and made 120 binds across six uneven categories unreadable; it also
sized the card from a child that filled it, which is a circular binding
and produced a card taller than the display with its contents running
off the bottom. Both were found by looking at it rather than by a test,
which is the argument for looking at it.

Fixes a real bug on the way past: luaChord and formatChord appended the
key unconditionally, so the window switcher's modifier-only release
bind became "SUPER + " with a dangling separator. That matched neither
the chord keybinds.lua binds nor the one an override is keyed by, so
that bind could never be rebound and had no category -- it was sitting
in a seventh group of its own, which is how it was noticed.
2026-08-21 23:53:50 -04:00
Gabriel Brown 6ae8265730 Say what a keybind is for, rather than guessing from its name
The Shortcuts page grouped shortcuts by matching substrings in their
descriptions, which put "Close window" and "Close the notification
list" in the same group and left anything phrased unusually in
whichever bucket matched first. The cheatsheet that comes next would
have inherited the same guesswork.

keybinds.lua says it outright now. Its sections already were the
categories, so a section sets one and the binds below inherit it: one
line per section instead of one per bind, and a new bind lands in the
category of the section somebody wrote it in without having to
remember anything.

Hyprland reports a Lua bind's dispatcher as __lua with a bytecode
offset, so nothing can be attached to a bind that survives into
`hyprctl binds`. The config writes a manifest at load instead, keyed
by the chord actually bound so the shell can join on what it sees.
Writing never raises: a read-only state directory costs the grouping,
never the keymap, and the shell keeps the old derivation as its
fallback so a machine that has not reloaded its compositor still works.

The one failure mode is a section that forgets to set a category and
silently inherits the one above. That is not hypothetical -- it
happened while writing this, because the dictation section sits in the
middle of the media binds and its category leaked onto the volume,
media and brightness keys below it. The contract walks the file for
sections with binds and no category, and spot-checks the boundaries
where inheritance is doing the work.
2026-08-21 23:30:36 -04:00
Gabriel Brown e446a1072c Give an installed machine a way to catch up
./install only ever adds. It copies over /, links dotfiles, installs
packages -- and has no way to say "remove that file", "disable that
unit", "that symlink points nowhere now". So a machine set up months
ago keeps whatever this repository has since decided was wrong, and
the only thing that ever fixes it is somebody reading a commit
message. With a curl installer in the README, that stopped being
hypothetical.

A migration is one script that performs one repair, exactly once, on
the machines that need it. Named by the commit timestamp that authored
it, so glob order is chronological without a sequence number two
branches could both pick. Marked in ~/.local/state on success and only
on success, so a repair that failed stays pending rather than being
recorded as done and hidden forever. Ordered, and stopped at the first
failure, because a later repair may assume an earlier one landed. A
fresh install marks everything without running it, the way
Migrations.qml stamps a pre-versioning settings file at its baseline.

The first real one removes the dangling ~/.config/forge symlink left
behind when the GNOME session was cut: link-dotfiles could link it but
never unlink it. Verified both ways -- a no-op on a machine that never
had it, an actual repair on one that did.

Root work goes through panama-sudo --reason so the password prompt
names the repair, and the contract fails any migration reaching for
bare sudo.
2026-08-21 21:01:31 -04:00
Gabriel Brown 12d538c4af Lock on SUPER+Backspace too
A second door to the same logind lock signal, beside CTRL+ALT+L. The
keymap tables say so too.
2026-08-21 20:01:18 -04:00
Gabriel Brown 9f563c8f94 Configure the Kuycon by what it is, not where it is plugged in
The panel's 4500x3000 mode, 1.5 scale, and 10-bit request were a rule
for connector DP-2 outright, which handed them to whatever monitor a
stranger's machine had on its most common DisplayPort connector. The
rule is now matched by description, the per-output prefs loop covers
every connector including DP-2, and the displays contract pins the
policy to the description rather than the port.
2026-08-21 17:43:33 -04:00
Gabriel Brown cab7699711 Start the text expander that was only ever installed
espanso-wayland was installed and its config linked, and then nothing
anywhere launched it -- the RPM ships no unit, autostart.lua started
every session daemon except this one, and the seed machine only worked
because the service had once been registered by hand. change-settings
now runs `espanso service register` (upstream's own mechanism, and
unlike hypridle it conflicts with nothing GNOME runs), and autostart.lua
starts the unit so the first Hyprland login after a fresh install
expands text rather than the second.
2026-08-21 16:10:32 -04:00
Gabriel Brown 7cd4131327 Hold a key, speak, and the words are typed
Super+D holds the microphone open, releasing it transcribes on the GPU and
types the result wherever the cursor is. Roughly 150ms for a normal utterance
once the model is resident, measured rather than hoped for.

Getting there meant discarding two approaches. Fedora 44 cannot install any
GPU-capable Whisper for Python -- openai-whisper needs a numba that needs an
llvmlite that does not exist for 3.14, and faster-whisper needs a ctranslate2
nobody packaged. The whisper-cpp package IS built with HIP but ships libraries
with no binary and no bindings, and hand-writing ctypes for a large by-value
struct is a segfault waiting for a version bump. So a container, as suggested.

Vulkan rather than ROCm, and upstream's image rather than one built here. ROCm
is seven gigabytes and serves AMD alone; Vulkan compute runs on the AMD, Intel
and NVIDIA machines this config is used on, in a twentieth of the space. The
Vulkan tag already contains whisper-server, so there is no Containerfile to keep
working -- an earlier draft of this commit had one, and it was strictly worse.

Two bugs found by using it rather than by reading it. Whisper describes silence
as the literal text "[BLANK_AUDIO]", and the first working version pasted that
string into the clipboard; a transcription that is nothing but such markers is
now discarded. And the server answers with a line per segment, which typed into
a window is an Enter press -- sending the half-written message, submitting the
form. Whitespace is collapsed to one line.

Neither the image nor the model is installed by ./install. Together they are
over two gigabytes that want the network, and Settings offers both as one
action instead. Nothing starts at login either: whisper-server holds the model
from the moment it starts, so the first press of the key is what brings it up.

The contract pins both text bugs, that the server stays on loopback, and that it
does not start at login. Reverting the [BLANK_AUDIO] guard did not fail it at
first -- the check was still correct, it had simply stopped being called -- so
it now checks the call site too.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 12:22:19 -04:00
Gabriel Brown 4c77bc2f61 Decide whether the other screens join in on workspaces
GNOME's Multitasking panel asked one workspace question worth reproducing, and
it is not which workspace goes on which screen. It is whether the second screen
participates at all: workspaces on the primary display only, or each screen with
its own. Ten rows of per-workspace assignment would be more powerful and worse.

Off is Hyprland's own behaviour and emits nothing. On pins workspaces 1 to 10 --
however many ALT+1..ALT+0 actually reach, read from keybinds.lua rather than
written down twice -- to whichever output is recorded as primary. With no
primary recorded, nothing is pinned: guessing one would move every workspace
onto whichever output happened to sort first, and this machine is in exactly
that state.

Applying is a reload, which is the part that shaped the design. Hyprland reads
workspace rules at config time and will not remove one afterwards -- a rule
written with an empty monitor keeps its old binding, which was checked rather
than assumed. Only a reload clears them, so the config is the only honest source
and the page cannot pretend a change has landed before one happens. Hence a
service that reads `hyprctl workspacerules` back rather than inferring success
from having written the preference, and a Reload row that exists only while the
two disagree.

Verified end to end against the live compositor and put back: off emits nothing,
on emits ten rules naming the primary, and turning it off clears them. The
settings file came back byte-identical.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 02:16:29 -04:00
Gabriel Brown 9092a80f66 Search from the launcher, and give the touchpad something to do
Four things a Hyprland desktop can do that this one was not.

Searching from the launcher needed no launcher work at all: Vicinae already
models it, so this is a script command with one percent-encoded argument. Make
it the fallback command and anything typed that matches nothing else offers to
search it. Bangs come free -- they are a property of where the query is sent,
not of the launcher -- so !yt reaches YouTube without a line of bang parsing.

Suggestions could not be a script command. They need a view that reacts as you
type, which is an extension: TypeScript, compiled, querying the same endpoint
Firefox's address bar uses. It debounces, and aborts the request in flight on
every keystroke -- typing is faster than the network, and an older answer
landing after a newer one leaves the list describing a query that is no longer
on screen. A bang skips suggestions entirely, because Google has no useful
guesses about "!yt".

The engine is now written down twice, once in each. The contract pins that they
agree, since searching from the fallback and searching from the suggestions
reaching different places is the kind of wrong that looks fine.

Gestures mirror GNOME: three fingers sideways for workspaces, up for the
overview, down to dismiss it. Open and close rather than toggle both ways --
toggling means swiping up from an open overview closes it, which is not what the
fingers meant. Hyprland reads gesture registrations at startup so they cannot be
a setting, but distance and direction can be, and are.

Window swallowing is off by default and a preference like every other misc
setting here. A terminal that vanishes when you did not ask for it is confusing
rather than broken, which is worse.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 01:49:29 -04:00
Gabriel Brown e1faaf7a76 Drop the extension, and give the test suite a front door
Phase 6, the last of the fresh-install spec.

159 scripts lose their .sh: 110 contracts, 47 Vicinae commands, 2 compositor
contracts. A shebang and the executable bit already select the interpreter. The
extension only ever added something that had to stay in sync, and the rename
proved the point twice over in the space of an hour.

The spec's stated risk was Vicinae's script discovery. One script was renamed and
reloaded on its own before the other 46 followed; it came back as
scripts:panama.capture and all 47 resolve. What the probe turned up instead is
that the extension was never only a filename: Vicinae's command IDs embed it, so
every ID changed. Nothing in this repository refers to them, so nothing breaks.
The only trace is Vicinae's metadata.json, whose visited map had two Panama
entries that are now orphaned -- two commands lost their usage ranking and will
earn it back. Worth knowing before anyone renames these again on a machine that
has a keybind pointing at one.

Rewriting the references by exact filename missed two things it structurally
could not see: a name built from a variable, settings-$page.sh, and a glob,
-name '*.sh'. Both were in the contract that counts the generated commands, which
promptly reported 47 expected and 0 found. The mechanical part of a rename is the
part that looks finished.

The three subcommands. panama doctor fronts a health check that already existed
and already ran at the end of every install but could not be reached from a
terminal. panama upgrade re-runs the installer from anywhere. panama test runs
the suite, which had no entry point at all -- 121 files that were the main safety
net in this repository and were invisible in it.

Writing that runner found three tests nothing was running.
calendar_agenda_bridge_test, home_assistant_bridge_test and kdeconnect_bridge_test
are unittest suites without the executable bit, so no contract invoked them and
the first draft of the runner skipped them silently. All three pass, and have
passed unobserved for weeks. The runner collects *_test.py as well now, because a
runner with a blind spot is worse than no runner for the same reason a dependency
checker with one is: it reports PASS.

Six worktrees pruned. Each was re-checked rather than trusted to the spec's list,
and two needed it: panama-commands is not on feat/panama-commands but on
feat/gnome-tweaks-parity, and fix/panama-displays-review reads [ahead 3] -- ahead
of its remote, not of main, with every commit patch-equivalent to landed work.
roadmap-completion stays; it has five commits that are genuinely unlanded. The
branches are left alone: pruning a worktree costs nothing, deleting a branch is a
decision.

121 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:55:55 -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 116510caa8 Draw the authentication prompt ourselves
hyprpolkitagent's dialog is compiled into its binary -- no config, no
stylesheet, nothing to theme -- and it was the one window on this
desktop that looked like it belonged to something else.

The split between the two halves is the security design, not an
implementation detail. A small agent process owns the D-Bus side: it
registers with polkitd, receives the request, and hands the shell the
action, the message, who may answer, and a one-time cookie. It never
sees a password. The shell draws the prompt and, on submit, spawns the
setuid polkit-agent-helper-1 itself and writes the password to that
helper's stdin; the helper runs the PAM conversation and reports to
polkitd directly. The password exists in the shell and in the helper's
stdin and nowhere else -- never on a command line, never over D-Bus,
never through IPC arguments.

The prompt takes exclusive keyboard focus, because a password field that
lets keystrokes reach the window behind it is a keylogger with extra
steps. The request travels as a file created 0600 with O_EXCL inside a
0700 runtime directory: a cookie is not a password, but it is a
capability, and capabilities do not belong in a process listing either.

Three things cost real time. polkitd calls back on the same connection
that registered, so exporting the object on the session bus while
registering from the system bus failed every request as "Not authorized"
with no error anywhere. XDG_SESSION_ID is absent in a systemd user unit,
which runs under [email protected] and belongs to no login session, so the
session comes from logind's Display property instead. And PyGObject does
not accept the @ placeholder in variant format strings.

hyprpolkitagent stays installed as the fallback, only one agent is
started, and the comment beside the autostart says how to get the stock
prompt back. Verified end to end, including a real password accepted and
three cancellations refused.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:08:52 -04:00
Gabriel Brown a68e4f6dcd Make every settings page reachable from the launcher
Settings had a search index and the launcher had script commands, and
neither knew about the other: finding a setting meant opening Settings
first and searching there. One command per page is now generated from
the same sources the in-app search reads -- the sidebar page list, the
group routing, and the schema labels -- so a setting searchable inside
Settings is searchable from the launcher without a second list to
maintain.

Generating it surfaced a gap in the in-app search as well. Six pages had
no vocabulary at all, because their contents come from the system rather
than our schema, so searching "volume" found nothing in Settings either.
They now carry entries, which improves both surfaces.

The product prefix is dropped from every hand-written command title. The
generated ones are qualified "Settings: <page>" instead: a bare page
label collides with the feature of the same name, and two commands
sharing a title are indistinguishable in a launcher.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:47:07 -04:00
Gabriel Brown 2b3b762793 Install what the default-application roles are curated to
Seeding is deliberately conservative: a role whose preferred applications
are all missing is left alone rather than forced. That is right, and it
is also silent -- so a curated handler nobody installs presents as the
machine quietly going back to deciding defaults by installation order,
which is the problem seeding exists to fix. None of Loupe, Papers,
Decibels, Nautilus, mpv or sushi was declared anywhere.

Preview works, on org.gnome.NautilusPreviewer2 rather than the interface
its bus name suggests, but it opened tiled -- shoving the file manager
aside for something meant to be an overlay -- so it gets a float and
center rule sized to leave the file underneath visible.

xdg-utils and desktop-file-utils were undeclared too; seeding from
link-dotfiles started calling them, and the dependency contract said so.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:35:12 -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 ac231eeb54 Stop double-launching Nextcloud and Bitwarden at session start
Both already have a ~/.config/autostart/*.desktop entry, and systemd's
own xdg-autostart generator turns that into a graphical-session.target
unit that fires on its own -- confirmed live via `systemctl --user
list-units 'app-*@autostart.service'`. autostart.lua was also launching
both explicitly, on the (apparently outdated) assumption that nothing
else would. For Bitwarden this was actively harmful: each `flatpak run`
gets its own sandbox instance, so the duplicate launch left two
processes fighting over the app's single-instance lock, with neither
reliably owning a usable window -- this is what "can't open Bitwarden"
traced back to, alongside a live document-portal fuse mount that had
silently died (fixed by restarting xdg-document-portal.service; not a
config issue, so nothing to commit there).

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:35:31 -04:00
Gabriel Brown 8cf03d4529 Remove dead shell components and fix stale docs
NotificationCenter.qml, CalendarPopup.qml, and RecordingIndicator.qml
were never instantiated anywhere -- shell.qml builds NotificationList,
DateMenu, and CaptureOverlay in their place. Verified with a repo-wide
grep before deleting; updated the two stale comments in Notifs.qml
that still pointed at NotificationCenter.

DESKTOP-PARITY.md still described Wi-Fi QR sharing as deliberately
omitted, though it was since built. The System Health colour-profile
handoff pointed at GNOME's colour panel as if it worked, but the
daemon that actually loads an ICC profile doesn't run in this session,
so it silently does nothing -- reworded the card to say so. Displays
carried two verify-timer attempt counters that were incremented but
never read anywhere.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:32 -04:00
Gabriel Brown 719ef2f38e Fix a keybind collision and wire two orphaned prefs
SUPER+SHIFT+P was bound to both the colour picker and a window-resize
action; moved the resize bind to SUPER+SHIFT+Comma, next to its
existing N alias, and updated the README's keymap table to match.

workspaceBackAndForth and allowWorkspaceCycles were declared in the
preference schema but read nowhere, so they had no effect regardless
of what a user set them to; wired both into keybinds.lua's workspace
config, which is where they actually apply.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:19 -04:00
Gabriel Brown 9ba224d776 Finish threading the accent colour through the whole desktop
The recent accent-colour setting only reached part of the desktop.
looks.lua still hardcoded the focused-window border and glow to blue,
so any hyprctl reload -- or every fresh session, for about a second --
reverted a chosen accent; it now reads accentName the same way it
already read colorScheme. The lock screen and terminal stayed blue
regardless of the chosen accent despite the setting's own description
claiming otherwise; panama-lock and panama-theme-apps now resolve and
apply the real accent.

AccentPicker built its swatch model from Theme.accents directly
instead of the schema's own options list, so the two could drift
silently; switched it to read the schema. Its hit target only covered
the swatch, not the name label added specifically for colour-vision
accessibility -- extended to the whole row. Settings search had no
route for the "appearance" group, so searching for the accent or
colour scheme landed on Home.

ColorScheme's hex-to-Hyprland helper assumed 6-digit colours and would
silently corrupt a future translucent one; fixed it to read from the
end of the string instead of the start. An accent-only change no
longer reruns the full colour-scheme pipeline. The gradient it builds
for the focused border now goes through SystemSettings' existing
serialiser instead of a second, under-escaped copy of the same logic.

The settings-ownership contract test enforced the old rule that
ColorScheme must never touch the focused border; updated it to verify
the real, intended rule instead of contradicting the code.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:16 -04:00
Gabriel Brown 70d8d32ee2 Fix the install pipeline and an idle-lock startup race
The initial package list was quoted into a single bogus dnf argument
and every dnf error was discarded, so a fresh install silently skipped
most of it. Two package lists were never wired into the pipeline at
all, and change-settings ran before install-packages, so the vicinae
theme step was permanently skipped. Fixed the ordering, the quoting,
and stopped swallowing errors.

Separately, hypridle could start with its WAYLAND_DISPLAY condition
unmet if it raced the env-publish call, silently never starting --
and it's the only listener for the logind Lock signal. Made the start
wait on the environment synchronously. panama-idle also wrote its
generated config to a fixed temp path with no locking, so concurrent
applies could interleave into a corrupt file; switched to mktemp plus
an atomic mv.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:22:52 -04:00
Gabriel Brown d0d9e196b0 Persist complete monitor layouts 2026-08-18 15:00:18 -04:00
Gabriel Brown 3520700983 Route session locking through Panama 2026-08-18 15:00:18 -04:00
Gabriel Brown 1360a80f07 Add a visible window switcher
Super+Tab already cycled windows, but nothing was drawn, so you chose
blind and could only confirm the choice by arriving. A visible switcher
is muscle memory for anyone arriving from macOS or GNOME, and it was the
last item of roadmap phase 03 that did not need coordination.

Ordered most-recently-used, not by creation, because that is what makes
the gesture useful: one Tab returns to the window you just came from.
Hyprland does not report an MRU order, so it is tracked from focus
changes and keyed by address, which is the only property stable for a
window's lifetime.

The gesture needs three binds rather than two. Tab steps the selection,
and the switch is committed on Super RELEASE -- the only way the
compositor can say the gesture is over. That bind is on the bare
modifier, so it fires on every Super release in the session; commit()
returns immediately when nothing is open, which is what makes it
affordable.

A list of names rather than thumbnails: at a glance you are looking for
"the other terminal", and a row of live previews is slower to read and
far more expensive to draw than this gesture deserves.

The interesting part is the bug. The overlay was built, mapped nothing,
and logged absolutely nothing -- because it declared `required property
var screen` while Variants supplies `modelData`. shell.qml has carried a
comment warning about exactly this since the Bar hit it, and I read that
comment earlier in the same session and still walked into it. A comment
that does not stop the person who read it is an argument for a test, so
per-screen-surface-contract now checks every per-screen delegate takes
its screen from modelData. Verified it catches the exact mistake.

Also fixes a regression from 8be3fc2: settings-pages-contract still
required vitalsIntervalMs on Home, where it no longer is. That contract
was pinning the split-across-two-pages arrangement the same commit
fixed, and I pushed without running it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:56:15 -04:00
Gabriel Brown 2d69ce7648 Make the lock screen follow the colour scheme
hyprlock.conf shipped with Tokyo Night Moon hardcoded in six places, so
choosing light mode left the lock screen dark. Every other surface had
been taught to follow the scheme this week -- kitty, GTK, the launcher,
btop, tmux, neovim -- and this was the one left, which is unfortunate,
because it is the screen a user sees most often and the worst possible
place to find a theming bug: you discover it while locked out of the
machine and cannot fix it from there.

It is now generated from a template on every scheme change, the same
shape kitty, GTK, tmux and btop already use, and seeded by link-dotfiles
so the first lock of a fresh install is themed rather than falling back
to hyprlock's bare grey default. hyprlock is launched fresh on each lock
(`pidof hyprlock || hyprlock`), so it picks the file up with no restart.

The dark output is byte-identical to the file it replaces, ignoring
comments -- verified by diff -- so nothing changes for anyone already in
dark mode.

One detail worth recording: hyprlock takes rgba(r, g, b, a) in DECIMAL,
not hex, so the template carries "R, G, B" triples where every other
theme file in this repository uses hex. Two values are the exception,
sitting inside Pango markup where hyprlock wants ##rrggbb. Getting
either wrong is not a parse error -- hyprlock ignores the value and uses
its own default, silently.

Which is why this has a contract. It generates both schemes into a
fixture, never the live config, and checks that no placeholder survives
substitution, that every colour is a well-formed decimal triple, that
the Pango values are well-formed hex, that a light lock screen is
actually light, and that the two schemes differ at all. Verified it
catches a hardcoded colour left in the template and a light mode built
from the dark palette, which is the original bug exactly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 14:36:35 -04:00
Gabriel Brown d87f4b6d6a Define Settings ownership boundaries 2026-08-18 13:27:52 -04:00
Gabriel Brown b8832a0174 Preserve the GNOME Caps Lock behavior 2026-08-18 13:01:28 -04:00
Gabriel Brown 3f07d25858 Merge current Panama main 2026-08-18 12:58:40 -04:00
Gabriel Brown df1dcdfad5 Add effective desktop style controls 2026-08-18 12:57:54 -04:00
Gabriel Brown f9eba1e8c5 Add curated XKB option presets 2026-08-18 12:55:54 -04:00
Gabriel Brown b1edfb6fe4 Merge Panama health hardening 2026-08-18 12:51:14 -04:00
Gabriel Brown ccf46c40ef Expose 19 more compositor options that only looks.lua could reach
Measured the gap first: of the 38 real Hyprland options Panama's own Lua
sets, only 16 were editable in Settings. Everything else required a text
editor, which is the thing this app exists to stop. This closes most of
that: 66 mapped options now, from 47.

Window shape and shadows on Appearance: corner shape (rounding_power),
focused and fullscreen opacity, shadow falloff and hard-edged shadows.
Window edges, master layout and Hyprland's own notices on Desktop & Dock.

Three of these are corrections rather than additions.

Master layout options existed nowhere, while Settings has offered "Master
and stack" as a choice since this morning -- a layout you can select and
cannot configure is barely a choice. Its card is hidden unless that
layout is actually selected, since settings that do nothing under the
layout you are running are worse than not offering the layout at all.

The four Hyprland notices -- logo, splash, update news, donation nag --
are all turned off by looks.lua on the user's behalf. Defensible as a
default, but not a decision anyone could reverse. They are stored
positively ("show this") and written as Hyprland's `disable_*` through a
new `invert` flag, because a switch labelled "Disable splash text" that
must be ON to hide something is a small cruelty. The Lua does the same
inversion so both sides agree.

Everything new also reads from prefs in looks.lua. Without that these
would apply live and silently revert on the next compositor reload,
which is the failure this codebase keeps designing against.

Two shapes the write path had never seen. Border colours are gradients
and shadow offsets are vec2, and the verifier understood neither -- it
returned false for anything outside int/bool/float/str/css, so both
would have reported every write as rejected. Gradients also need real
care: the stubs declare them as `string|{colors,angle}`, and the string
form carries only ONE stop, so writing "rgba(a) rgba(b) 45deg" as a
string is accepted and keeps the previous value. Verified that directly.
They are also written in one notation and read back in another
(`{colors={"rgba(3b426199)"},angle=45}` becomes `993b4261 45deg`), so
comparison normalises both sides.

Border COLOUR is deliberately not exposed yet. col.inactive_border is
written by ColorScheme on every scheme change, so a user's choice would
be silently overwritten, and col.active_border is the Prism gradient,
which needs a colour control this app does not have. Shadow offset is
left out for the same reason -- the vec2 support is in place for
whenever the widget exists.

Verified each new option applies and reverts against the live
compositor, and that the schema, enum-map, nav, write and commit/reset
contracts all pass.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 12:27:34 -04:00
Gabriel Brown f8e7512e01 Document Panama health and recovery 2026-08-18 11:36:06 -04:00
Gabriel Brown 0c4d132ee1 Fix focus-mode labels that said the opposite of what they did
Found by codex's GNOME Tweaks audit and verified against the compositor:
`hyprctl descriptions` publishes input:follow_mouse as
map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}].

Panama labelled 0 "Never", 1 "Click to focus", 2 "Sloppy focus". So this
desktop, sitting on the shipped value of 1, has been running
focus-follows-pointer the whole time while Settings called it "Click to
focus" -- and the way to actually GET click-to-focus was to choose
"Never". Value 3 was not offered at all. hypr/input.lua carried the same
wrong claim in a comment.

The shipped VALUE is left alone. Which focus mode this desktop should
use is a behaviour decision rather than a correction, and all four are
now reachable from Settings.

Nothing could have caught this. The compositor accepts 1, reads back 1,
and the write contract passes: the value is valid, it just means
something other than the label. The only authority on what each number
MEANS is the compositor, and it publishes that. So enum-hypr-map-contract
now checks every compositor-backed enum against the published map --
that offered values exist, and that published values are offered, since
a missing one is a capability nobody can reach.

Writing it immediately found two more of the same: variable refresh rate
offered Off and fullscreen-games while the compositor publishes four
(always-on and fullscreen-only were unreachable, and fullscreen-only is
what someone wanting VRR for video rather than games wants), and direct
scanout was missing its always-on value. Both now offer everything, with
a detail line per option rather than a bare word.

Verified the contract catches the original followMouse gap and a value
outside the map.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 11:13:42 -04:00
Gabriel Brown 9b2fa80ab7 Drop the product branding: this is just Settings
It is the system settings app for this desktop, so it is named the way
one is. The window is "Settings", the titlebar is "Settings", the
wordmark above the search field is gone, the sidebar's last row is
"About", and the status line reads "Desktop is healthy".

Prose followed the same rule. Forty-odd strings explained what "Panama"
does -- "Panama never animates while idle", "Restore Panama defaults",
"Panama looks in ~/Pictures/Wallpapers" -- which is how a product
describes itself, not how a settings panel describes a setting. They now
say what happens. No user-visible "Panama" remains anywhere in the app.

Two consequences worth naming:

The SUPER+I shortcut's description is user-visible, because the
Shortcuts page is generated from it, so that is renamed too. Rebindings
are keyed by shipped chord rather than description, so no existing
override is orphaned by this.

Three contracts matched the window by title and one matched that
shortcut by description; all four are updated. There are no Hyprland
window rules keyed on the title, so nothing about the window's placement
changes.

The desktop entry is now Name=Settings, but the FILE keeps its
panama-settings name, as does the icon: the dock pins applications by
desktop id, and renaming the file would silently unpin it.
dock-pins-contract covers exactly that.

The dated design docs under docs/superpowers keep the old name. They are
a record of what was decided when, and editing them to agree with the
present would make them lie about the past.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 10:08:55 -04:00
Gabriel Brown 9c574f9eb7 Add a light mode that keeps the same identity
Light is Tokyo Night Day, the palette's own light variant, rather than
one invented to merely not be dark. Both share the same hues at
different lightness, which is what lets the Prism signature survive the
switch: blue still leads into orchid, it is simply a darker blue on a
lighter ground.

Every colour token became a binding on one boolean, so flipping it
repaints the whole shell without any component needing to know it
happened. That only worked because nothing outside Theme.qml defines a
colour; the two places that did are fixed here.

Surface alphas differ by scheme. The 0.34 that reads as glass over a
dark desktop reads as haze over a light one, and text stops being
legible on it.

Two things that draw on this desktop do not read Panama's store: GTK
applications, which read gsettings, and the compositor, which draws
window borders. A toolbar or a border still wearing the other scheme is
more jarring than either scheme on its own, so ColorScheme pushes the
choice to both. It also pushes at startup, since a scheme chosen in a
previous session would otherwise be in effect only for the shell.

The unfocused window border follows too. It is a flat neutral, and a
dark neutral is invisible against a light desktop. The focused border is
the prism gradient and needs no variant.

The live preview follows the scheme as well. A preview that stayed dark
while the shell around it went light did not read as "your desktop", it
read as a screenshot of someone else's.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:20:35 -04:00
Gabriel Brown d1b2cd2083 Make typography choosable instead of hardcoded
Theme.qml was the largest remaining thing in this desktop that could
only be changed by editing a file, and typeface is the first thing
someone changes when they want a desktop to feel like theirs. Interface
font, icon font, and the base text size are settings now.

The two font choices are deliberately separate lists. Theme.fontMono is
used only to draw glyphs -- workspace pills, the status cluster, search
icons -- so a plain monospace family there replaces every icon in the
shell with tofu. The picker offers only Nerd Fonts for that slot and
says why.

Candidates are rendered in the family they name. A list of font names
set in the current font tells you nothing about what you are choosing.

The four type sizes derive from the base rather than being stored
separately, so the relationship between body, caption, heading and title
survives a change instead of four numbers drifting apart.

hypr/looks.lua reads the same key. Following the preference only on the
QML side would leave the compositor and the shell disagreeing about the
interface font, which nobody notices until a tooltip renders in a
different typeface.

Only a family this machine reports is accepted: the value reaches
hl.config as a string, and a settings file moved between machines will
name fonts that are not installed. A missing family is reported rather
than silently substituted by fontconfig.

Also collapses the wallpaper grid to two rows. Sixty tiles is two
screens of pictures on a page that also holds typography, window
geometry and effects -- everything below it was unreachable without
scrolling past all of them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 07:06:53 -04:00
Gabriel Brown 668983e16d Add polished system feedback overlays 2026-08-18 06:46:11 -04:00
Gabriel Brown 2d4b126f14 Harden display apply and recovery 2026-08-18 02:55:43 -04:00