Author SHA1 Message Date
Gabriel Brown a69cacc006 WIP: Paused over-hardening fix wave (boot checkout verification, double-read package manifest) 2026-09-17 11:44:01 -04:00
Gabriel Brown 28e387868f Docs: Record signed Terra bootstrap proof 2026-08-27 11:13:59 -04:00
Gabriel Brown 1f65e09865 Test: Prove verified bootstrap boundaries 2026-08-27 10:44:14 -04:00
Gabriel Brown dce00e45d1 Docs: Pin the verified Panama bootstrap 2026-08-27 10:14:55 -04:00
Gabriel Brown 0d1841cf86 Fix: Verify the initial Panama revision 2026-08-27 10:06:13 -04:00
Gabriel Brown ba4e5e6677 Fix: Lock installer input state 2026-08-27 09:48:28 -04:00
Gabriel Brown 1ffd05f0e1 Fix: Re-run verified installer inputs 2026-08-27 09:34:20 -04:00
Gabriel Brown 92c60c3ecd Fix: Close runtime provenance residuals 2026-08-27 09:22:17 -04:00
Gabriel Brown 31fc1d3eaa Fix: Harden pinned runtime activation 2026-08-27 08:32:44 -04:00
Gabriel Brown 8264ca6320 Fix: Pin runtime and agent artifacts 2026-08-27 07:49:12 -04:00
Gabriel Brown eab4280969 Fix: Parse complete Terra repository state 2026-08-27 07:25:25 -04:00
Gabriel Brown 1c3cd7ac72 Fix: Enforce effective repository trust preflight 2026-08-27 07:10:11 -04:00
Gabriel Brown 8652f92aae Fix: Validate existing package repositories 2026-08-27 06:46:36 -04:00
Gabriel Brown 4abf3f8acd Fix: Verify third-party package repositories 2026-08-27 06:27:35 -04:00
Gabriel Brown 3f8539176a Test: Strengthen ambient GPG isolation contract 2026-08-27 06:10:32 -04:00
Gabriel Brown 18ecc2244f Fix: Isolate installer GPG inspection 2026-08-27 06:05:42 -04:00
Gabriel Brown 94a4314730 Fix: Harden installer provenance boundary 2026-08-27 05:56:46 -04:00
Gabriel Brown 27acbe7a95 Test: Add installer provenance boundary 2026-08-27 05:43:26 -04:00
Gabriel Brown fc8f226747 Fix: Complete SSH bootstrap hardening 2026-08-27 05:19:58 -04:00
Gabriel Brown 98e29eb8f3 Test: Strengthen SSH bootstrap documentation contract 2026-08-27 04:35:45 -04:00
Gabriel Brown 00e11e0b8d Docs: Explain transactional SSH hardening 2026-08-27 04:25:05 -04:00
Gabriel Brown ac25fd776c Fix: Clean interrupted SSH candidates 2026-08-27 04:12:45 -04:00
Gabriel Brown 51e2c8418a Fix: Complete SSH hardening recovery 2026-08-27 04:06:54 -04:00
Gabriel Brown d267da58ad Fix: Roll back failed SSH hardening 2026-08-27 03:44:57 -04:00
Gabriel Brown 20b41c3976 Fix: Gate SSH hardening on a verified key 2026-08-27 03:28:42 -04:00
Gabriel Brown bfb37afd69 Plan secure bootstrap and privileged installation 2026-08-27 03:11:00 -04:00
Gabriel Brown b361db8486 Design secure bootstrap and privileged installation 2026-08-27 02:23:55 -04:00
Gabriel Brown 12b858371e Fix: Close verification gate review findings 2026-08-27 00:21:45 -04:00
Gabriel Brown 77f625d7bb Docs: Clarify contract output handling 2026-08-26 23:38:09 -04:00
Gabriel Brown 34e295d184 Docs: Explain contract capability gates 2026-08-26 23:32:38 -04:00
Gabriel Brown e19fb00276 Test: Harden dock fixture cleanup 2026-08-26 23:22:26 -04:00
Gabriel Brown 37688f702a Test: Isolate dock geometry fixtures 2026-08-26 23:12:14 -04:00
Gabriel Brown 71f4f0d24b Test: Guard settings docs contract cleanup 2026-08-26 23:05:14 -04:00
Gabriel Brown 3839183401 Test: Isolate generated docs and settings fixtures 2026-08-26 22:59:28 -04:00
Gabriel Brown 68b6664111 Test: Propagate update fixture failures 2026-08-26 22:53:39 -04:00
Gabriel Brown 803362ebad Test: Exercise updates through the real command 2026-08-26 22:46:46 -04:00
Gabriel Brown 6a7bdcb835 Test: Harden compose secret fixtures 2026-08-26 22:38:00 -04:00
Gabriel Brown 37fd5e890e Test: Scan compose secrets by data shape 2026-08-26 22:31:22 -04:00
Gabriel Brown a19c6dc3ef Test: Make agent usage fixtures date independent 2026-08-26 22:26:19 -04:00
Gabriel Brown 6d71a77798 Test: Cover contract runner consent 2026-08-26 22:22:57 -04:00
Gabriel Brown d8144a66c1 Fix: Make contract execution safe and diagnostic 2026-08-26 22:11:56 -04:00
Gabriel Brown 43d1e15858 Test: Harden manifest validation 2026-08-26 22:00:52 -04:00
Gabriel Brown 1192ad64dc Test: Classify every contract capability 2026-08-26 21:52:16 -04:00
Gabriel Brown 044139ed63 Plan verification gate remediation 2026-08-26 21:11:25 -04:00
Gabriel Brown 0d894afe3e Plan repository audit remediation 2026-08-26 20:42:59 -04:00
Gabriel Brown 54dfa977ee The notification menu learns what every other popover already knew
Popover's container is a plain Item and never measures its children, so
the three-dots menu on a notification card opened as a 240x28 sliver
with its one row clipped out of existence. TrayMenu and AgentUsagePanel
both carry the antidote — implicit sizes on the Popover itself, taken
from the content column — and now the notification settings menu does
too, with rows filling the window width so the hover reaches the edges.

Claude-Session: https://claude.ai/code/session_01W8icivxZcmFTR2g6DkYhjT
2026-08-26 19:50:31 -04:00
Gabriel Brown 5f09c5ee38 The document portal may not claim started until its mount exists
Third recurrence of the stale-mount bug: the ExecStartPre cleanup ran on
clean ground and the portal still came up mountless, sitting active while
every fresh flatpak sandbox failed to build. Add an ExecStartPost that
polls for the fuse mount and fails the start after five seconds without
one, so a mountless portal shows up failed in the journal and the next
bus activation retries from the cleanup instead of lingering broken.

Claude-Session: https://claude.ai/code/session_01CTL7G5u3iDf1NCM6rTyCES
2026-08-26 10:48:47 -04:00
Gabriel Brown f33da41cc6 Panama learns what a server is: from a root login to running containers
A machine's role is now the interview's first question and the one answer
Panama records. Servers get the same shell minus the screen: core packages,
nvm, Bun, Claude Code and Codex (desktops get Codex too), linger, rootless
ports from 80, firewalld, the nginx-bridge network, and a nightly image
updater that replaced watchtower for cause.

server/containers/ carries junior's 23 compose services -- secrets moved to
per-machine .env files that never enter this public repo, every transformed
compose proven to render byte-identical to what is live. 'panama server'
enables, disables and relinks them; nothing here restarts a running service.
'boot --server' walks a fresh VPS from its root login to a normal install.

Five new contracts pin the secrets rule, the catalog's shape, panama-server's
behavior, the role plumbing, and the dotfile classification.

Claude-Session: https://claude.ai/code/session_01NU5JGiN3JfzqrLQB6wmJ1E
2026-08-25 23:11:49 -04:00
Gabriel Brown 9b338608ef Center the usage pill like every other tenant of the bar
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 13:15:42 -04:00
Gabriel Brown cc7d91d09c No error is a dead end: crash, click, and your agent is already looking
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 12:50:09 -04:00
Gabriel Brown ada0faf1d1 Let the sidebar breathe: fourteen categories deserve legible names
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 11:20:34 -04:00
Gabriel Brown 045a774847 The machine now carries its own manual for AI hands
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 10:09:38 -04:00
Gabriel Brown beed44dd87 panama update: one command, no questions, and no gap it cannot see
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 07:22:12 -04:00
Gabriel Brown 8105849151 The last two stale needles the great respelling left behind
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 02:31:59 -04:00
Gabriel Brown bd2fd12de2 Coherence: one component per idea, one spelling per word, one truth per claim
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 02:21:45 -04:00
Gabriel Brown 06c53d6c21 Shortcuts you invent, rules you write, gestures you own - all still just data
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 01:51:59 -04:00
Gabriel Brown f9e5d3f470 No forgetting, deleting or clearing on a single press, anywhere
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 00:41:39 -04:00
Gabriel Brown 88371d19f0 The safety layer: two presses for anything you cannot take back
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 00:39:07 -04:00
Gabriel Brown 8b1205e4b8 Tier 0: render what the services already decided, honestly
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-25 00:24:30 -04:00
Gabriel Brown be0e55214b Finish the wonderland: System told truthfully, in eight tabs instead of ten
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 23:31:52 -04:00
Gabriel Brown 9ffaf45a4d Make every settings row reachable, and every accessibility switch honest
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 21:03:36 -04:00
Gabriel Brown e1ff25fc66 Draw idle as one timeline, and let the power button answer to its owner
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 20:33:44 -04:00
Gabriel Brown 6f0ce639d9 Show every answer the portal remembers, and give SSH keys their missing half
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 19:26:56 -04:00
Gabriel Brown 4ec8bd94d9 Give identity its due: native enrollment, honest deletion, and sign-in that stays home
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 18:55:38 -04:00
Gabriel Brown 5a0643357f Make Applications a real app manager, and clean up storage without the racket
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 17:18:14 -04:00
Gabriel Brown b30bf40407 Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 16:31:52 -04:00
Gabriel Brown aba2d16ffa Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 15:21:01 -04:00
Gabriel Brown b8f88a91f3 Add the four components the Focus commit referenced but forgot to ship
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 15:14:02 -04:00
Gabriel Brown d5b6e62515 Bound the notification app list, and give Focus a real editor
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 14:25:26 -04:00
Gabriel Brown 07db1068f1 Review everything shipped this weekend, and fix what the reviewers caught
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 13:29:42 -04:00
Gabriel Brown ffce48964e Make the Jira skills call unslop, grilling, and tdd where they pay
All three already delegated in places — ticket calls diagnosing-bugs and
run, review-ticket calls codebase-design — so this extends a habit rather
than starting one.

unslop is the load-bearing part, at four points: create-ticket before the
Jira write, review-ticket's check list where a bare punctuation sweep
stood, and ticket on both mr.md and the Jira fields. Every one is scoped
to pattern detection and skips the "Adding soul" section, which is blog
voice and would fight the rigid house formats. The mr.md hook exempts the
pasted pre-MR handoff, generated output the skill already forbids
editing, and the field hook exempts proof cells. Conrad and Henry read
all of this, and prose that reads as machine generated costs the work its
credibility before anyone weighs what it says.

grilling comes in as a classification gate rather than blanket ceremony.
ticket now names the work bounded or architectural before drafting
plan.md and escalates only on architectural, where a wrong assumption is
expensive to find in Phase 2; bounded stories go straight through as
before. create-ticket escalates only when the epic docs have not settled
scope, which is where invented scope gets in.

tdd is gated the same way: test-first for procedures, derivations,
permission checks, migrations and state machines, and explicitly not for
UI wiring or copy changes, where it would produce the smoke-test padding
House style rejects. That closes a loop already half built, since
review-ticket proposes automated-test subtasks — those subtasks now name
the behavior to demonstrate, and ticket writes them first.
codebase-design gets the same call in ticket's planning step that
review-ticket already makes.

No steps were renumbered, so ticket's Phase 2 cross-references still
resolve.

Claude-Session: https://claude.ai/code/session_01DFgQVLdzLwu56gU8m4gvCS
2026-08-24 11:58:01 -04:00
Gabriel Brown b58371bb35 Give Sound the whole story, and keep the buttons inside the card
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 11:39:16 -04:00
Gabriel Brown 9bc68ba358 Rebuild Displays around the canvas, and let the transaction keep color
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 10:44:11 -04:00
Gabriel Brown 1f694b00b6 Keep .bashrc a bootstrap, and secrets out of the checkout for real
.bashrc is back to its one job: export the Panama paths and source
what it finds. The personal-env block moves into config/bash/shell —
first, because the tmux guard below it reads that file — and the
cargo source that was duplicated between the two files lives only in
shell now.

The real fix is behind that tidying: both Home Assistant helpers
defaulted to the IN-REPO config/bash/env, and the writer rebuilt it
with only its own three lines — which read as "my env vars vanished"
to the person who thought that file was hand-maintained. Both now
prefer ~/.config/panama/env, the migrated home outside the checkout,
with the repo path kept only as a read fallback for unmigrated
machines. The stale legacy copy on this machine is retired; the
working credentials were merged into the migrated file first, after
probing both sets against the live Home Assistant.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-24 08:49:40 -04:00
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 4d7a194300 Make video wallpapers survive first contact
Shipping met reality tonight, and reality won three rounds before we
did. mpvpaper's -f forks it into the background, which a supervisor
reads as instant death — the respawn loop repainted the desktop black
once a second. The reaper's own kills fired onExited like crashes, so
the supervisor ate its young until deliberate deaths got marked. And
the video's path shared wallpaperPath with the still pipeline, whose
transactional persistence clobbered it — it now lives under its own
videoWallpaperPath key, read by the lock screen and the doctor too.

Restore is the service's own now, reactive and once per session: a
one-shot timer raced the async preference load and availability probe
at cold start and silently lost. A video-wallpaper IPC target drives
start/stop/pause from a terminal and from the contract sweep to come.

Verified live: one player per output, same PID across restarts, VAAPI
engaged, 0.2% CPU steady for 1440p30 h264, pause from the bar pill.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 23:57:04 -04:00
Gabriel Brown cb7c09d208 Give the desktop real themes, video wallpapers, and honest titlebars
Appearance now opens on Themes: light and dark side by side, each
remembering its own choice, over galleries of ten shipped themes —
Tokyo Moon and Day joined by Moon Rose, Catppuccin, Nord, Gruvbox and
Everforest in both modes. A theme is a complete palette: the catalog
lives in themes.json, Theme.qml reads every color token from the
active record, and one render pipeline carries it to kitty, tmux,
btop, GTK, Vicinae, Firefox's chrome, and the lock screen. The Theme
editor builds new ones from four wells — wheel, hex, or eyedropper —
with derived surfaces, a saturation slider, debounced fine-tune, and
effects that save with the theme. Custom edits finally keep GNOME's
accent, kitty's border, and hyprlock in sync.

Wallpapers can be video: mpvpaper per output, hardware-decoded, muted
and looped, supervised and respawned. Panama owns the pausing — games,
battery, and a bar pill for right now — because the compositor
rebuilds full-screen blur for every frame a video wallpaper draws.
The lock screen gets a still frame.

Titlebars stop lying. GNOME apps get close-only on your chosen side,
the maximize and double-click settings are gone, the Settings window
obeys the same rules, and its titlebar can be turned off entirely.
Typography becomes five labeled dropdowns instead of a wall of
samples.

Contracts updated and written throughout (165 now); per the redesign
workflow none were executed — the full sweep runs once at the end.

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 23:39:04 -04:00
Gabriel Brown 7578348db1 Merge Home & Phone into a three-tab Home that knows your house
Home is now Overview | My Home | Phone. Overview leads with quick-action
tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the
findings card — updates fold in, the reclaim-space prompt is gone on
purpose — and adds glance cards, the next calendar event, and weather.

My Home groups every light by Home Assistant area: the helper gained an
`areas` command (one REST template render, no websocket), and the rooms
degrade to a flat list on setups without areas. The favorites editor and
connection card moved intact. Phone gains a vitals strip — battery and
cell signal read from KDE Connect's plugin D-Bus objects, where absence
is data, not an error — beside ring, clipboard, send-a-file, and the
BlueBubbles handoff.

The retired home-phone id resolves to my-home forever via a new alias
map in SettingsRoutes (with a hasOwnProperty guard so prototype names
cannot leak into settingsPage). Storage no longer claims 0 B free — the
old page read a field the disks helper never emitted.

Contracts updated alongside; per the new workflow, the full suite runs
once at the end of the redesign (see the test backlog note).

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
2026-08-23 22:06:18 -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 50077a0c31 Fix the second dead settings button, and pin the class shut
A sweep for controls wired to targets the backend does not handle -- the shape
of the dictation bug -- found one more: "Start Orca" on the Accessibility page
called openApplication("orca") with no "orca" in the command map, so it set an
error and launched nothing. orca ships in hyprland-packages; it now runs.

That is the whole count. Every service-method call across 84 settings pages
resolves, every helper subcommand a service invokes is implemented, every
openGnomePanel handoff is allow-listed. Two dead buttons existed in the entire
settings app -- the dictation Download and this -- and both are fixed.

settings-buttons-contract pins the class: every openApplication id and
openGnomePanel panel a QML button passes must be present in SystemSettings'
dispatch maps, both of which return false silently on an unknown name.
Verified it catches the orca button when the fix is reverted.

And a dependency-contract exception the ffmpeg fix needed: ffmpeg is provided
by a swap (ffmpeg-free -> ffmpeg), never a list entry, because listing it is
the conflict that fix removed -- so panama-transcode's use of it is satisfied
without a package name to point at.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 14:11:15 -04:00
Gabriel Brown 14fc4fc8a3 Make the dictation setup button call a command that exists
The Download button on Sound → Dictation ran `panama-dictate download`. There
is no such command: the helper answered {"ok": false, "error":
"unknown-command"} and the button did nothing, every time, for anyone who
tried it. The real command is `setup`, which pulls the speech server image
and downloads the ~490 MB model together -- one trip, by design. And the card
told you the server needed `panama app whisper-vulkan`, an app that exists in
no package or setup/apps entry; the server is a prebuilt image the helper
pulls with podman.

So the card described three things and got all three wrong. It now shows the
real state of both pieces and offers one action -- "Set up dictation" -- wired
to the command that actually fetches them, with progress that names which half
is running (server image, then model). Verified end to end: `panama-dictate
setup` emits the pulling/downloading JSON the service parses.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 13:59:42 -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 c0eee076b4 Stop installing ffmpeg over ffmpeg-free, which a fresh machine refuses
A clean Fedora Workstation ships ffmpeg-free, and desktop-packages listed
ffmpeg (RPM Fusion). Those two conflict rather than add: dnf will not erase
the preinstalled ffmpeg-free to make room, so the WHOLE desktop transaction
failed to resolve and no desktop package installed. The codec section already
does the trade correctly -- `dnf swap ffmpeg-free ffmpeg --allowerasing` --
so ffmpeg simply does not belong in the eager list. A machine that already
had ffmpeg (every one this repo was ever run on) sailed past this; the first
genuinely fresh Workstation install is what surfaced it.

Also: report_missing false-warned that awk was unavailable. rpm -q
--whatprovides matches a package's named provides, but awk is provided as the
file path /usr/bin/awk (by gawk), which that query misses. Fall back to
command -v so a capability provided by path is not reported as missing.

Found by a fresh-VM certification run, which is exactly the failure mode a
re-run on an already-configured machine cannot reproduce.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 13:20:42 -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 153554b5df Make ./install something you could hand a stranger
The audit's third tier: everything between this installer and a fresh machine
it has never met.

The one path that could cost a person their display: the interview probes
Secure Boot with mokutil, which install-packages had not installed yet, so on
a minimal base the MOK question silently never fired -- and install-hardware
still installed akmod-nvidia and blacklisted nouveau, arming a reboot into an
unloadable driver with its fallback disabled. The probe tools (pciutils,
mokutil, fwupd) now bootstrap beside gum, and install-hardware re-checks
Secure Boot for itself and refuses the driver rather than the display.

Secrets leave the checkout: the personal environment moves to
~/.config/panama/env at mode 600 by migration, and .bashrc sources it with a
permission check that quietly re-tightens drift. change-settings no longer
overwrites /etc/dnf/dnf.conf -- two performance keys are set additively, the
defaultyes=True that made every `dnf remove` treat Enter as yes is gone, and
a migration strips it from machines that already received it.

Package installation survives the world changing: the initial and desktop
lists run with --skip-unavailable and a report_missing pass that names what
was skipped (resolved through --whatprovides, so capability names like awk
do not cry wolf); the openh264, appstream and core-group extras go through
soft; RustDesk resolves its RPM for the machine's own architecture; and the
Claude Desktop repository script is fetched to a kept file and run, never
piped from the network into root.

The hardware predicates stop guessing: a wireless mouse's scope=Device
battery no longer turns a tower into a laptop, USB-PD-only machines read
their power state from the battery's own status instead of being permanently
"on AC", the lid falls back to logind's LidClosed where ACPI is silent, and
charge limits reach every pack of a two-battery machine in one authorization
-- with the reported percentage summed across packs.

And the parsers stop assuming this machine: snapper is read through
--machine-readable csv with named columns instead of a localized box-drawing
table, and reports whether snapshots are even possible so ext4 and
unconfigured-btrfs stop looking identical; fprintd is parsed under LC_ALL=C;
the hypridle drop-in resolves the binary it points at; the recorder's render
node became an "auto" token resolved at record time; update-grub writes the
config its firmware actually boots; the nvm prompt hook and the SSH tmux
takeover are guarded; hipblas and rocm-opencl move to an opt-in gpu-compute
category; and the two interactive python tools' libraries are declared.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 12:10:03 -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 dfc0c49877 Let the laptop say what it is doing: battery percentage, the lid, a fingerprint
Three surfaces the first laptop install showed were missing.

The bar's battery icon gets an optional exact number beside it -- GNOME's
"Show Battery Percentage", off by default for GNOME's reason, one color with
the icon so it reads as one indicator.

The Power page says what closing the lid does. The policy already existed
(LidPolicy holds a suspend inhibitor while an external display is connected)
but was surfaced nowhere, so the machine's most physical behavior was
undiscoverable -- and the deliberate absence of an override deserves stating
rather than leaving someone to hunt for a switch that does not exist.

And the Users page grows a Fingerprint card, because fingerprint login is two
systems that fail silently when they disagree: fprintd holds the enrolled
prints, authselect decides whether PAM ever asks the reader. This machine
arrived with a finger enrolled from its GNOME days and with-fingerprint off,
which reads as "the reader is broken". The card shows both facts, flips the
authselect feature through polkit with a stated reason, and hands enrollment
to GNOME's Users panel, which owns the only good capture dialog -- a named
exception in the handoff contract. Everything through scripts/panama-fingerprint,
pinned by a stub-driven contract.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:07:37 -04:00
Gabriel Brown c2eddc2c5b Ask for the password once, and own the plugin the COPR needs
Two ways a fresh machine's unattended run could still stall. The stages call
sudo dozens of times across twenty-plus minutes, and the timestamp expires
five minutes after whichever call came last -- so any single dnf step that
outlasted it turned the next stage into a password prompt nobody was there to
answer. ./install now authenticates once, right after the interview, and a
refresher holds the timestamp for exactly as long as the script lives;
cleanup() kills it on every exit path, so nothing outlives the install with
ambient credentials.

And `dnf copr` comes from dnf5-plugins, which a machine that has used copr
carries and a fresh Workstation does not -- a missing plugin there would have
cost the entire desktop. Declared in initial-packages, where the repositories
stage can rely on it.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 11:07:37 -04:00
Gabriel Brown 6ad2e017e8 Decide how to log out by what the session is, not what is installed
The power menu's Log Out ran `uwsm stop` whenever the uwsm binary existed --
which is always, since Panama installs it. In a plain "Hyprland" session there
is no uwsm unit to stop, so the command failed and the button silently did
nothing. Nor is `uwsm check is-active` the test: it reads
graphical-session.target, which the plain session also activates. The one
thing unique to a managed session is uwsm's own compositor unit,
wayland-wm@*.service, so that is now what chooses between stopping the unit
and asking the compositor to exit. A new contract pins the decision -- and
checks the script line rather than the file, because the comment explaining
the wrong tests has to be allowed to name them.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:38:09 -04:00
Gabriel Brown 12f6b2a310 Let the contracts assert properties, not the machine they were written on
The first run of the suite on a laptop found five contracts asserting the
desktop instead of the code. settings-system pinned DP-2 at 4500x3000 in
XRGB2101010; it now asks Hyprland what is actually primary. ssh-keys hardcoded
id_ed25519; it now uses whichever key exists. switcher's live half stepped a
session with one window, which step() deliberately refuses. displays raced the
service's revert readback -- the compositor looks restored while verification
still holds busy, so an immediate apply was refused with its error already
cleared; the harness now exposes settled and the contract waits for it.

declared-dependencies gets an OPTIONAL list for docker: the aliases serve
machines that run Docker deliberately, Panama's runtime is rootless podman,
and a missing docker fails by naming the command, which is loud enough.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
Gabriel Brown 6510fdda0f Reach DDC on GPUs that are not "VGA", and stop warning about an empty dock
ddcutil's udev rule grants the seated user the GPU's i2c buses only when the
PCI class is 0x030000. An AMD iGPU that is not the primary boot display says
0x038000, so on the Framework every DDC bus stayed root-only. Ship the same
grant for the class the hardware actually reports; change-settings installs it.

And two conflations in the probe: an undocked laptop reported its normal state
as an error, and doctor collapsed every error into "No accessible DDC/CI bus".
Nothing external connected is now a clean empty -- doctor's unconfigured path
-- and a real failure surfaces the probe's own words, because an unreadable
bus and a monitor with DDC/CI off in its menu have different fixes.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:32:17 -04:00
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 d865cb74a1 Never let a stale settings copy erase a saved choice
The preferences store was loaded once at shell startup, and every save wrote
the whole in-memory copy back. Anything that reached the file after startup --
a hand edit, a script, another shell instance flushing during a session
handoff -- was erased by the next unrelated save. That is how a display scale
set to 1.5 kept coming back as 2: the entry was written, then silently
clobbered, and the catch-all auto rule filled the gap.

Two halves, either alone insufficient. The store now watches its file and
adopts outside writes instead of holding a stale copy over them; and a save
merges with what is on disk rather than overwriting it -- keys this shell has
set win, keys it has never seen survive. A setting changed anywhere now
persists until it is changed again, which is the only behavior a settings
file is entitled to have.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-23 10:06:38 -04:00
Gabriel Brown a95c480c6a Drop the packages Fedora 44 stopped shipping, and never let a migration wait for a prompt
Two more things the Framework's install surfaced.

gstreamer1-plugin-fmp4 and gstreamer1-plugin-mp4 no longer exist in any repo
this configuration enables — not Fedora 44, not RPM Fusion, not the Hyprland
COPR. Machines installed on older Fedora carry them as orphans, which is why
the list looked fine from one. dnf5 refuses a whole transaction over one
missing name, and desktop-packages runs above the Hyprland block, so two dead
codec entries cost the entire desktop.

The solopasha migration preferred panama-sudo, and pkexec with no polkit agent
to answer it does not fail — it waits forever, which held an unattended
./install hostage at the one stage that promised never to ask. The stated
reason is now reserved for a terminal someone is sitting at; a non-interactive
run takes plain sudo, which either succeeds quietly or fails fast enough for
panama-migrate to retry at the next login, as it promises.

Verified by running install-packages end to end on the machine that broke it:
Terra guard holds, desktop packages resolve, Hyprland 0.56.2 lands, and the
stage reaches its stepped-over report. All 22 setup contracts pass.

Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
2026-08-22 10:42:58 -04:00
Gabriel Brown 78db2d5689 Let a stepped-over step say why, and let sudo ask
soft() swallowed stderr, which hid both halves of what matters: the dnf error
explaining the failure, and the sudo password prompt on a machine that asks for
one. stdout only.
2026-08-22 09:38:41 -04:00
Gabriel Brown 669a228286 Install the desktop before anything allowed to fail, and let Terra be installed twice
Two bugs, one story: ./install on a machine that had been half set up before
produced no Hyprland at all, and said so in one line among twenty minutes of
scrollback.

Terra bootstraps itself with --repofrompath, which defines a throwaway repo id
just long enough to install terra-release. Run it again on a machine that
already has terra-release and dnf5 refuses the whole transaction -- the
throwaway id collides with the real one. That step sits above everything, so
set -e ended the stage before a single package was considered. It is skipped
now when terra-release is already installed.

The rest is the reason one failed repo cost the desktop. Hyprland was installed
near the bottom of the stage, below a codec swap, two group updates and a
GStreamer glob, any one of which can fail for reasons outside this repository.
It now installs directly after the packages it needs and before anything
optional, and everything fragile below it runs through a soft helper that logs
and continues rather than ending the run. What was stepped over is listed at
the end, because tolerating a failure only beats aborting on it if somebody is
told.

A missing Hyprland is still fatal, and now says so in words.

Also removes the leftover disabled solopasha/hyprland COPR, which would mix
with lionheartp's the moment anyone enabled it while debugging.

Fixes the usage widget reading 1500%: the endpoint reports percentages, not
0..1 fractions. Clamped as well, and the widget answers a click now -- it set
interactive:false, which disables the mouse area its own handler needed.
2026-08-22 09:36:54 -04:00
Gabriel Brown 89761a7da3 Keep the personal half of the desktop in one place, and ask before installing it
Agent instructions, skills, SSH host aliases and expansion triggers are worth
having identical on every machine one person owns, and belong in none of the
shared configuration. They live in user/ now, with a manifest saying where each
piece goes and a link-user stage that puts it there.

That stage does nothing unless the machine said yes. Somebody who clones Panama
to try the desktop keeps their own ~/.claude/CLAUDE.md exactly where it was;
the question names the destinations and defaults to no. Anything displaced goes
to config/old rather than being deleted.

~/.claude/CLAUDE.md and ~/.codex/AGENTS.md were byte-identical copies of one
file, which is the drift this exists to prevent.

Also adds the vitals toggles for the battery and Claude usage readouts, which
had preferences and no way to reach them.
2026-08-22 08:54:43 -04:00
Gabriel Brown 8b96d907a1 Show how much of the subscription is gone, without risking the session
The last of Section F, and the only thing in Panama that reads an
authentication token, so most of the design is about that rather than
about the number.

It never refreshes the token and never writes to the credentials file.
That token expires roughly hourly and Claude Code refreshes it on
demand; if this refreshed it too, two processes would be rotating one
credential, and a rotation invalidates the other holder's copy. The
failure mode is being silently signed out of Claude Code by a status
widget, which no bar indicator is worth. So it reads the token, uses it
while valid, and reports "waiting for Claude Code to refresh" when not
-- which covers the case that matters, because while you are using
Claude Code the token is fresh, and while you are not there is nothing
to watch.

The token never reaches argv either: curl takes the Authorization
header on stdin through --config, because a header passed as an
argument sits in /proc/<pid>/cmdline for the length of the request.
Same rule the password and MOK paths already follow. And it never
reaches the output: the record carries percentages and timestamps and
nothing else. Both are pinned, and both were checked by sabotaging the
collector to pass -H and watching the contract name it.

Off by default. It is a coding-tool readout, not something a
general-purpose desktop shows without being asked, and it hides unless
the collector has real numbers rather than displaying "unknown".
2026-08-22 08:33: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 4910bd659c Say out loud that the portal backend is crash-looping
xdg-desktop-portal-hyprland 1.4.1 segfaults in its own shutdown path:
exit() runs the CPortalManager destructor, which tears down
wlr-foreign-toplevel handles and xdg-output proxies after the display
has gone, and dies in wl_map_insert_at. systemd restarts it, so the
desktop keeps working and the only symptom is screen sharing dropping
mid-session and the occasional document-portal casualty.

Sixty crashes in one day left no trace anywhere a person would look. It
took reading coredumpctl to find, and nobody reads coredumpctl on a
desktop that appears to be working -- which is exactly the shape of
problem the health page exists for. On this machine the new check
reported twenty-eight in the last day the moment it was wired up.

Reported rather than repaired, and it says so. There is nothing to
restart that systemd has not already restarted, and no newer build in
the Hyprland COPR to install: the fix is upstream. A button that did
nothing would be worse than a sentence that explains.

Below five in a day stays green, because an upstream bug that only
bites on shutdown will occasionally bite and a health page that is
never green teaches people to ignore it.
2026-08-22 07:38:41 -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 68cbf892e9 Teach the file manager to send and to shrink
Right-click a file to send it to your phone, or a video to make it
smaller. macOS has both behind the Share sheet and Quick Actions;
Windows has "Send to"; a stock Linux file manager has neither, and the
usual answer for the second one is a web uploader or an ffmpeg
incantation looked up again every time.

Neither adds machinery. Sending reuses panama-kdeconnect, the same
helper the Home & Phone page and quick settings already drive, so
there is one way to talk to a phone rather than two. The entry appears
only when a phone is actually reachable: an item that is present and
fails is worse than one that is absent, because the absence explains
itself.

The transcoder's two rules are both about not losing work. It never
writes to its input, and it never writes over an earlier output -- a
second run produces -2 rather than eating the first result. Verified
against a real encode: 1920x1080 became 854x480, with an even width
because H.264 rejects an odd one at the very end of a long encode,
which is the worst possible moment to find out.

Menus decide by mime type rather than extension, act on one file at a
time, and refuse anything that is not a local path. nautilus-python
turned out to be declared already; it now says it carries Panama's own
extensions too.
2026-08-22 06:52:04 -04:00
Gabriel Brown 05fd5346db Turn a website into an application, and notice the charger
Two of Section E's small wins.

panama-webapp gives a site its own icon, its own window with no browser
chrome, and its own launcher entry, which is most of what "installed"
means in practice and what both macOS and Windows now ship. It scrapes
the site's apple-touch-icon, falls back twice, and never fails an
install over a favicon: a web app with a generic icon still works.
Names are slugged, so "../../../../tmp/pwn" lands inside the
applications directory as tmp-pwn rather than anywhere else, and
remove refuses anything without the marker it writes -- sharing a name
with a real application must not delete that application. A browser
that cannot do app mode is told so rather than handed something that
opens an ordinary window and pretends.

The charger now announces itself through StatusEvents, which was
already the right layer and only wanted a producer. Ambient priority,
so Do Not Disturb quiets it, because a charger is exactly what DND is
for. A critically low battery is published at a priority DND does not
silence, because the one message you must not miss is the one saying
the machine is about to stop. Both join the existing silent-startup
window rather than announcing the state they found.

The keyboard-layout toast the plan also listed is deliberately not
built. Hyprland exposes the active keymap but not a change event
Quickshell already consumes, so it would need either polling or new
event plumbing, and this machine has one layout and could not test it.
2026-08-22 06:33:19 -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 202b5b89ac Write the manual for the person using the desktop
docs/ is engineering artifacts -- design specs, plans, an upstream
ledger -- and none of it is written for the person who has to live
here. Five chapters that are: coming from another desktop, the
keyboard, windows and workspaces, when something breaks, and making it
yours.

Rendered inside Settings rather than opened in a browser, so a chapter
that says "the Displays page reverts after fifteen seconds" is one
click from the Displays page. Qt's markdown renderer does the work;
one chapter per Text, because Text has an implicit texture size limit
and a document long enough to hit it goes blank rather than
complaining.

The chapters live beside the shell in manual/ rather than at the
repository root, which departs from the plan. The reason is the path:
the shell finds them through Quickshell.shellDir, which is correct
wherever the repository is, whereas walking upward out of the shell
directory is only correct by accident. The contract fails that pattern
if it comes back.

The contract also pins the set both ways -- every chapter listed
exists, every chapter that exists is listed -- because a renamed file
shows an error card where a chapter should be, which reads as a broken
manual rather than as a moved file.
2026-08-22 01:11:46 -04:00
Gabriel Brown 9202697734 Introduce the desktop to somebody who has just met it
Thirty settings pages is the opposite of the usual problem: a person
arriving from GNOME, macOS or Windows cannot tell which few things
matter. This is those few, once, on the first start.

Not a tour. Nobody reads a tour, and a multi-step wizard on a desktop
somebody just installed is one more thing between them and using it.
One card, five keys, and a way out.

The chords come from the live keymap rather than being written here, so
a machine whose owner has already rebound something teaches what they
actually have. A welcome screen is the one surface read by somebody
with no way to tell it is wrong, which is exactly why it must not be.

Two deliberate departures from how every other surface behaves. It does
not close on a click outside, because a stray click in the first thirty
seconds would throw away the only explanation on offer. And dismissing
by any route marks it seen, Escape included, because a desktop that
reintroduces itself every login has failed to take no for an answer.
It stays reachable from the launcher afterwards, since the moment
somebody wants it again is exactly when a one-shot has thrown it away.

Also teaches the keymap to spell punctuation: slash, period, comma and
the rest were rendering as their raw keysym names, so the welcome
screen offered "Super + slash" and the cheatsheet agreed with it.
2026-08-22 01:00:18 -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 317b7a0962 Give a reconnected display the arrangement it had
hypr/monitors.lua applies the stored per-output entries when the
compositor reads its config, and never again. A monitor plugged in an
hour later got the compositor's automatic placement instead of the
position, scale and rotation this machine was told to use, and the
only way back was to open Settings and apply it again. Docking should
not cost you your desk.

Deliberately not a confirmed transaction. applyLayout arms a fifteen
second countdown because it is about to show you something you might
not be able to undo; this restores a layout you already confirmed, on
hardware you already had, and a countdown would be asking you to
re-approve your own decision every time you sat down.

It refuses rather than guesses when the stored mode is one the
connected panel does not offer -- DP-1 on one dock is not DP-1 on
another -- and when the surviving layout would name no primary. Both
land on the compositor's automatic placement plus a toast that opens
the Displays page, which is recoverable; silence would not be. That
toast needed a new open-settings verb in StatusEvents, whose page name
goes through ShellState's existing allow-list.

The decision is split from the action as plannedRestore so it can be
tested without driving a real compositor, and the harness sets topology
and stored arrangement in one call because a real query landing between
two would replace the fixture. Both fixtures travel base64: qs ipc call
splits a JSON array of several objects into one argument per object,
so a two-monitor fixture was arriving as an extra argument.
2026-08-21 23:06:50 -04:00
Gabriel Brown 50a99a5ad0 Closing the lid at a desk is not closing it in a bag
logind handles the lid correctly except for the one case it cannot
see: an external display means a closed lid is a docked machine, not
one being put away. Its own docked test looks for an ACPI docking
station that modern hardware does not have.

Panama does not take the lid over to fix that. It holds a logind
handle-lid-switch inhibitor while an external display is connected and
releases it when the last one goes, which needs no lid watcher, no
polling, and no drop-in. The direction it fails in is the point: if the
guard dies, logind's default comes back and a docked laptop suspends,
which is annoying. A drop-in setting HandleLidSwitch=ignore plus a
watcher of our own fails the other way, leaving a lid that does nothing
at all on a machine being carried out of a building.

Locking on the way down needed no work: hypridle's before_sleep_cmd
already runs loginctl lock-session, so a lid-close suspend is a locked
suspend. The contract fails anything that duplicates it.

Not yet verified against a real lid, which is stated in the helper's
header rather than implied by silence. The decision logic, the
inhibitor's shape, and every machine that should hold none of it are
covered.
2026-08-21 22:36:36 -04:00
Gabriel Brown bc6d63b70f Let the idle timings know whether you are plugged in
An idle screen costs a screen on wall power and the rest of your
afternoon on battery, so they should not be the same number. hypridle
has no concept of a power source -- one config, one set of timeouts --
so rather than maintaining two configs and swapping them, panama-idle
builds the single config from whichever key set applies, and IdleLock
rebuilds it when the charger comes or goes. That runs through the same
400ms debounce a settings change uses, so a loose charger cannot
restart hypridle in a loop.

The battery keys fall back to their AC counterparts rather than to the
schema defaults. Without that, unplugging would silently override a
deliberately long timing with a shipped short one, which is the kind
of thing you would notice only by losing work.

A machine with no battery reads none of it and generates exactly what
it generated before. The contract pins that alongside the two obvious
directions, and was checked by sabotaging the detection to confirm it
fails rather than passing vacuously.
2026-08-21 22:18:29 -04:00
Gabriel Brown 3c359f3f7e Notice the battery, and the machine it is or is not in
Panama had no idea whether it was running on a laptop. No upower, no
battery, no lid, no AC: hypridle.conf says "This is a desktop" in its
own header, and that was true of the code as well as the machine.

panama-hw answers hardware questions one at a time, exits 0 or 1, and
prints nothing, so scripts, services and contracts all ask the same
way. The definition the rest of the laptop work hangs on is one line:
clamshell is lid-closed AND an external monitor. A machine with no
mains supply at all reports as being on wall power, because a desktop
cannot run out of it.

The battery service follows Vitals: sysfs through FileView, an
availability flag, and no subprocess on the timer. Globbing is the one
thing QML cannot do -- a battery is BAT0 or BAT1 or CMB0, mains is AC
or ADP1 or ACAD -- so panama-battery resolves the names once and the
shell reads the files directly after. Nothing falls back to a
plausible zero: a desktop shows no indicator, no card, and no charge
limit control where the firmware has no ceiling.

Also repairs two contracts that were already failing and had not been
noticed, because only the full suite runs them. The dependency
scanner treated line-initial variable assignments, case labels,
comments and heredoc bodies as commands, and `count`, `host`, `cancel`
and `import` are all real binaries on Fedora, so `command -v` could
not filter them out. It now drops comments and heredoc bodies and
requires a command to be followed by whitespace. Verified it still
catches a genuinely undeclared dependency rather than passing quietly.
The launcher command contract had not been told about the fourteen
commands added earlier today.
2026-08-21 21:43:14 -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 3c45d63085 Plan the climb to what an operating system comes with
Seven sections, twenty-one tasks, drawn from a survey of omarchy,
end-4, caelestia, DankMaterialShell, noctalia, HyDE, ML4W and
hyprdots. Migrations come first and everything else waits on them:
Panama has a curl installer now, so it has machines that must converge
on later versions, and change-settings can only ever add.

Four findings changed the plan while writing it, and are recorded in
it. There is no existing welcome design -- the fresh-install spec is
about installer correctness. StatusEvents is already a system-toast
layer, so that task shrank to adding two producers. hypridle has no
AC concept, so per-power-source timings regenerate one config rather
than maintaining two. And display hotplug does not re-apply stored
layout, which is the actual clamshell gap rather than the one guessed.
2026-08-21 20:48:56 -04:00
Gabriel Brown 32bebc2b07 One door per name on the IPC bus
A second IpcHandler with an already-used target does not error -- it
silently shadows the first, and for the polkit target that means the
agent's authentication requests stop reaching the prompt: every
password dialog on the desktop, gone without a message. That duplicate
nearly shipped once, because the handlers live scattered through a
long shell.qml. The contract pairs every IpcHandler with its quoted
target across the shell's QML (harnesses excluded -- each is its own
root), fails on any name declared twice, and refuses to pass on an
empty scan so a declaration-format change cannot quietly blind it.
2026-08-21 20:02:25 -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 c02329ac3c Copy a password without leaving a trace of it
The launcher's Copy Password command, built alone and last as the plan
required, because every line of it is the security design: the secret
travels rbw to wl-copy through a pipe -- never argv, never a file --
and the copy carries wl-clipboard's --sensitive hint, which vicinae's
clipboard history documents it ignores. That claim was not taken on
faith: a plain probe landed in the live history database and a
sensitive one did not, before any of this was written. A transient
timer clears the clipboard after thirty seconds. An unconfigured rbw
gets a setup message; a vault that locks between list and get gets an
honest failure instead of an empty copy claiming success. rbw joins
desktop-packages, and the contract pins the whole journey with a stub
vault, including that the secret never appears on a command line.
2026-08-21 19:38:38 -04:00
Gabriel Brown 4e978bf3b7 Teach the launcher what an operating system knows
The OS-parity batch from the vicinae plan, tasks 1 through 7. The audit
came back better than the plan guessed: the calculator already links
libqalculate, the built-in file index answers in under 100ms across all
of home, quicklinks and snippets ship as built-in stores -- so zero new
packages, and `vicinae dmenu` replaces the planned compiled extension
outright. What was missing gets built: a power menu (lock, suspend, log
out through uwsm, restart, power off), reminders as transient systemd
timers with a pick-to-cancel list, a color picker over hyprpicker, and
dmenu pick-lists for window switching, force quit, kill process, SSH
hosts, and recent files -- all through one panama-pick helper. The
launcher commands contract exercises the reminder parsing and every
pick-list against stubs, including killing its own sacrificial sleep.
2026-08-21 19:32:11 -04:00
Gabriel Brown 1e430881e5 Plan the launcher's climb to OS parity 2026-08-21 19:14:20 -04:00
Gabriel Brown f42b3cfe0e Let the password prompt say why
panama-sudo is pkexec with a stated reason: the reason travels to the
shell over the existing polkit IPC target, and the prompt renders it
labeled "Stated reason (unverified)" beside polkitd's real action
message -- beside, never instead of, because any process can claim any
reason and the action text is the trust anchor. Reasons are single-shot
and expire in ten seconds, so a stale one cannot dress up an unrelated
prompt; without a reason, a running shell, or qs the wrapper is exactly
pkexec. Built for agents, so the person typing their password learns
what for. Verified live end to end -- reason shown, consumed once,
expired when stale, cleared on dismissal -- and pinned by the polkit
reason contract.
2026-08-21 18:57:26 -04:00
Gabriel Brown 51ceb19480 One command from fresh Fedora to the front door
`boot` is the script the README now leads with: curl it, and it installs
git if the machine lacks it, clones the repository to PANAMA_PATH, and
hands off to ./install -- reattaching the terminal first, because a
piped stdin would strand the interview. Deliberately dumb: a curled copy
leaves the repository the moment it runs, so nothing that can drift
lives in it. Re-running is the recovery path: an existing clone is
fast-forwarded, never re-cloned, and a refused fast-forward installs
from what is there rather than stopping mid-repair. All of it pinned by
the boot contract, against stub git and a throwaway clone.
2026-08-21 18:47:17 -04:00
Gabriel Brown 25e2328658 Your name is an answer, not a constant
The :fn/:em espanso triggers hardcoded one person's name and email in a
match file shipped to every user -- a misfire that lands inside their
own prose, where they are least likely to notice. setup-identity now
seeds match/identity.yml, per-machine and gitignored, from the same
interview answers that already configure git.
2026-08-21 17:43:33 -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 7d633eb06e Alias only what the machine will actually have
vim aliased to vimx, which no list installed; bat invoked by its Debian
name, which Fedora does not ship; sunshine restarted by an alias when
nothing here installs sunshine. vim-X11 is now declared so vimx exists,
ff previews with bat, and the sunshine alias moved to the gitignored
env of the one machine that has it.
2026-08-21 17:43:33 -04:00
Gabriel Brown 6d1f3f3763 Keep the MOK password off the command line it was promised off of
The comment above the prompt says the password never reaches a command
line; --generate-hash="$first" put it on one, world-readable in
/proc/<pid>/cmdline while mokutil ran. It now arrives on stdin, and the
hash is taken from the last output line because mokutil prints its
prompts to stdout too.
2026-08-21 17:43:33 -04:00
Gabriel Brown de923cb4d5 Honor PANAMA_PATH in the two stages that clobbered it
install and link-dotfiles treat it as a default; install-packages and
change-settings overwrote an exported override, so a clone anywhere but
~/.local/share/Panama sourced the extras catalog from a path that does
not exist and copied config/copy from the wrong tree.
2026-08-21 17:43:33 -04:00
Gabriel Brown bd9a55c8eb Fail when failing, stop when stopped, and survive what is neither
Four installer bugs, all in the space between exit codes and intent:

- A flatpak-only extras category -- most of them -- died at the grep
  that filters out its dnf half, because grep exits 1 on zero matches
  and set -e read that as failure. sed deletes lines without editorial
  comment. The extras contract now runs a flatpak-only category under
  the installer's own strict options so this stays fixed.
- A rate-limited GitHub API call aborted the whole package stage while
  resolving the RustDesk URL, even though the empty-result fallback was
  sitting right below it. The pipeline is now guarded so the fallback
  is reachable.
- Ctrl-C did not stop the install: the INT trap ran cleanup and bash
  carried on with the remaining stages, MOK enrollment and firmware
  included. INT and TERM now exit explicitly; cleanup rides EXIT.
- change-settings and link-dotfiles ran without set -e, so a failed
  copy over / or a failed symlink fell through to guarded no-ops and
  the stage reported success. Turning strictness on immediately caught
  what it had been hiding: link-dotfiles never created ~/.config, so on
  a truly fresh HOME every symlink was failing silently.
2026-08-21 16:10:42 -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 86825e7327 Judge the document portal by its mount, not by its service
No flatpak would launch. Every one of them failed in bwrap with "Can't find
source path /run/user/1000/doc/by-app/<id>", because xdg-document-portal's fuse
mount was gone -- /run/user/1000/doc was a plain empty directory. That mount is
bound into every sandbox bwrap builds, so losing it takes out all 34 flatpaks at
once, never a subset.

It had been gone for three days. The shipped unit is Type=dbus with Restart=no,
so nothing retries it on failure: after it exited 21 it came back only because a
flatpak called its bus name two seconds later, and that activation landed on the
dying instance's mountpoint and came up with no mount at all. systemctl reports
active (running) either way -- the fusermount3 helper is still sitting there as a
child, in this case for two and a half days without ever completing the mount.

Nothing running notices, which is what makes it so quiet. A sandbox needs the
mount only while it is being constructed, so everything already open keeps
working and the symptom arrives whenever you next open a flatpak you had not
opened yet. Here that was three days later, and it presented as "gearlever is
missing" -- an application that was installed, healthy, and entirely innocent.

Two changes, because there are two failures: it does not recover, and nothing
says so.

The drop-in clears the mountpoint before each start, so an activation that
follows a crash lands on clean ground. ExecStartPre rather than ExecStopPost
precisely because nothing restarts this unit -- the next start is whenever
something next wants it, and that is the moment that has to be safe. `-` because
a clean start has nothing to unmount and fusermount3 exits 1 saying so.
/etc/systemd/user rather than a per-user drop-in so it covers every account, and
change-settings reloads the user manager so it applies without a re-login.

The check asks the mount table whether $XDG_RUNTIME_DIR/doc is mounted
fuse.portal. Deliberately not a service probe, and deliberately not folded into
desktop.portals: that one asks about xdg-desktop-portal, a different service
which was up and healthy throughout. Service state is exactly the question that
lied here, so asking it again in a new place would have been no check at all.
Warning carries a restart repair, verified end to end rather than assumed.

The mount table is injectable, like every other path this script reads, so the
contract covers unmounted, wrong-filesystem-at-the-right-path, and unreadable
against written fixtures rather than against whatever /proc happens to say --
coupling the test to this machine's live flatpak state is the same mistake in
miniature. Stubbing the check to always return ok fails the contract, which was
confirmed rather than hoped.

What is not fixed is the crash itself: one occurrence, and restarting the
service to get the desktop working destroyed the evidence. The exit was 21, it
landed 21 seconds after xdg-desktop-portal restarted, and that is one sample and
not a theory. What this buys is that the next one is a two-second blip the
doctor names, rather than three silent days.

Second time for this bug. ac231ee found the same dead mount in August while
chasing "can't open Bitwarden", fixed it by hand, and recorded it as "not a
config issue, so nothing to commit there". That judgement is why it was paid for
twice, and it is the part most worth writing down.
2026-08-21 15:26:20 -04:00
Gabriel Brown 89417cd6d4 Take Claude Desktop from the repository that now carries it
It was `panama app claude-desktop`: a source build, because nothing packaged it.
Upstream publishes an RPM repository now, so the exception shrinks to adding
that repository -- and the result upgrades with `dnf upgrade` along with
everything else instead of needing a slow rebuild every time a version ships.

That moves it out of setup/apps/ and into the third-party section of
install-packages, beside Bun, Claude Code and RustDesk. It also means it is part
of ./install for the first time. The reason it was kept out was the build: slow,
wants the network throughout, and fails on an upstream that moved, which is the
failure the interview exists to prevent. A dnf install is none of those, and the
default dock pins com.anthropic.Claude, so a fresh machine was shipping a pin
for something the install had never put there.

The repository is added with upstream's own setup script rather than a .repo
file written out here. A baseurl copied into this repository is a pin by another
name; the script is the part upstream keeps correct. Both halves are skipped
when already present, so a re-run costs nothing.

The desktop entry id does not change -- it is still com.anthropic.Claude -- so
the dock pin and the notification rules carry over untouched. The new package
drops the nodejs dependency, which is what used to hide the missing-npm bug the
launcher search contract guards; its comment said "depends" in the present
tense and now says what actually happened.
2026-08-21 14:06:26 -04:00
Gabriel Brown 41b86c7a9e Pin the Claude entry that exists rather than the one that used to
The dock shipped a pin for claude-desktop, and the application it names has
since been repackaged: the entry is com.anthropic.Claude now, and only the
binary kept the old name. A pin that resolves to nothing does not announce
itself -- the dock simply has one fewer icon than the settings say it should.

Found by the dock pins contract, which exists for exactly this and had been
passing until the application updated underneath it.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 13:56:47 -04:00
Gabriel Brown 185d7edaa5 Open the windows you open together, together
Arrange the desktop, run "Save Layout as Project" from the launcher, name it.
"Open Project" lays it out again on free workspaces, so it never lands on top of
what you are already doing. Saved layouts are listed on the Desktop settings
page, which is where they are removed.

Recorded rather than written by hand, and a terminal's directory is most of why
it is worth having: without it a project opens three terminals in your home
folder and you change directory three times. This machine had two terminals in
the same project when it was written, and reopening there is the difference
between a layout and a working desktop.

Four things had to be found by running it, none of which reading would have
shown.

A terminal's directory is not the terminal's working directory -- that is where
it was launched from. The shell inside it is what has been cd'd. Reading the
wrong one looked correct for exactly as long as the terminals under test had
been started from the right place, which they had.

gtk-launch cannot place a window. It activates over D-Bus, so the process
Hyprland started exits and a [workspace N silent] rule has nothing left to apply
to; Nautilus landed on whatever workspace was in front of you. The Exec line
from the desktop entry is run directly instead. But DBusActivatable
applications do the same thing regardless, so the window is found afterwards and
moved by address -- which also means a window that never appeared is reported
rather than assumed.

/proc/PID/task/PID/children is a file of pids, not a directory. Listing it as
one always raised, so the fast path was never once taken and everything went
through pgrep.

And kitty's --directory needs an equals sign or the short -d; the
space-separated long form is accepted and silently ignored.

The contract exercises a save and open round trip against a stubbed compositor,
and reads a terminal's directory out of a real process tree rather than grepping
the source for a shell name -- an earlier version passed against a helper that
had been changed back, because the constant was still there.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 13:45:16 -04:00
Gabriel Brown 538c0a887c Save things where the rest of the software already saves them
Screenshots and recordings offered three folders to choose between, and three
guesses cannot include the folder somebody's other software already writes to --
which is the only folder that matters. This machine has had ~/Pictures/Screenshots
and ~/Videos/Screencasts since long before Panama, and Panama was writing
recordings to a Videos/Recordings it invented. Both are free text now, and the
recording default is the folder that was already there.

Wallpapers were swept from four directories at once, so the distribution's stock
images arrived mixed in with the user's own and there was no way to ask for just
one. Where wallpapers live is something somebody knows about their own machine.
It is a setting, not a search.

All three accept an absolute path as well as one relative to home, which meant
fixing Capture: it prefixed $HOME unconditionally, so naming /mnt/captures would
have written screenshots to ~/mnt/captures and left nobody able to find them.

The generator turned out to skip any entry whose comment sits inside the braces
rather than above them -- it looks for `key:` immediately after `{`. Three
settings were invisible in the reference because of it, one of them dockScreens,
which has never appeared there at all. The staleness contract could not see it
either: regenerating reproduced the same omission, so the copy was current and
incomplete at once. It now counts what was declared against what it could read
and refuses rather than quietly documenting less than exists.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 12:57:47 -04:00
Gabriel Brown 033d5b21f8 Build the launcher's extension on a machine that has only nvm
The search extension is compiled, and the stage that compiles it checked for npm
and skipped quietly when it found none. On this machine it always found one --
but only because Claude Desktop depends on nodejs and dragged a system npm in.

Nowhere else would. Node moved to nvm when the shell config turned out to have
been assuming it for months, and nvm is a shell function in a file only an
interactive shell sources; a stage is not one. So a fresh install would have set
up the launcher, printed one line about extensions not being built, and left
somebody wondering why typing in it suggested nothing.

The stage sources nvm before looking, and the contract pins that it does --
checking for npm is not the same as being able to find it.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 12:31:34 -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 b280bd02d7 Let the Home Assistant contract skip a server that is not there
It already meant to skip when no token was configured, and could not: the helper
exits 2 for any catalog it cannot complete, so `set -e` aborted at the capture
before the skip was reached. The contract failed with no output at all, which
reads as a crash rather than as a skip, and the branch written to prevent that
had never once run.

So the status is taken deliberately, and there are now two skips rather than
one. No token is not a defect in Panama. Neither is a bridge that does not
answer -- off the network, VPN down, the server asleep -- which is the same
reasoning the extras contract already uses for dnf and Flathub being
unreachable.

Only "unreachable" skips, which the helper raises exclusively for a timeout or a
socket error. A bridge that answers and refuses still fails: authentication
rejected, a bad response, a catalog of the wrong shape. Those are what this
contract is for, and all seven paths were exercised against a stubbed helper to
confirm which fail and which do not.

129 contracts pass, with none of them red for a reason nobody intends to fix --
which was the point. A suite expected to be red stops being read.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 02:56:24 -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 62dce86b4e Let the prompt survive not being installed yet
oh-my-posh moved from a curl installer to a Fedora package, and the shell config
invoked it unconditionally. That is fine during a full install, where
install-packages runs before the bashrc is linked -- and not fine in every other
order: a stage re-run by hand, an install that failed partway, or the moment
between removing the old binary and installing the package.

Same guard as the nvm source above it. An unthemed prompt is a worse shell; one
printing command-not-found before every prompt is a machine that looks broken.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:45:15 -04:00
Gabriel Brown 725e274ef4 Install the Node this shell config has always assumed
config/bash/shell sources /etc/profile.d/nvm.sh, switches Node per project from
.nvmrc, and puts PNPM_HOME on PATH. None of it worked on a fresh machine. nvm
was never installed -- it is a Terra package, present here since before Panama
-- and the source was unconditional, so every shell on a new box opened with an
error before it got as far as failing to find nvm.

That is the second instance of the same bug. $HOME/.cargo/env was the first, and
fixing it one file at a time is why this one survived: the dependency contract
scanned setup/scripts, bin and the quickshell helpers, but never config/bash --
the one place in this repository whose entire job is to name tools and source
the files that provide them.

So it scans it now, and checks the shape rather than the instance: a literal
path sourced without testing it exists is a finding, wherever it appears. It
found the nvm line, and authselect behind the fingerprint aliases.

Node and pnpm move to nvm with it. They were declared as dnf packages while the
machine ran them from ~/.nvm, which is not a preference so much as a
contradiction -- a system Node earlier on PATH wins every `nvm use`, so the
per-project switching this shell config sets up could never have worked. nvm
install --lts, then pnpm inside it, so pnpm travels with the Node version it
belongs to instead of outliving it.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:30:48 -04:00
Gabriel Brown f09763ef5d Check the two facts the README states about itself
It claimed 121 contracts when there were 125, one day after the number was
written, and it documented every panama subcommand except the one added last --
so `panama apps` existed and the README did not mention it.

A number in prose is worth something as a claim somebody relies on and nothing
once it is wrong, so it is either checked or it should not be there. This checks
it, counted the way the runner collects the suite rather than by a second idea
of what a contract is, and checks that every subcommand the README documents is
one the dispatcher actually handles -- a listed command that errors reads as a
broken install rather than a stale document.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-21 00:11:04 -04:00
Gabriel Brown 215da285f3 Let applications be chosen a few at a time
The catalog held fourteen applications. This machine runs thirty-four flatpaks,
so most of what is actually used had no way to be installed from here at all --
Zoom, Slack, Obsidian, Spotify, LibreOffice, OBS and its sixteen plugins.

So the catalog is seeded from the machine, and `panama apps` opens it: pick a
category, tick what you want, install just those. ./install still offers the
same catalog as whole categories, because during a first install you want coarse
and fast. Both read setup/lib/extras-catalog. Two parsers would eventually
disagree about what a category contains, and the one that disagreed quietly
would be the one that runs unattended.

Two pieces of syntax earn their keep. A `| Name` suffix gives the menu something
readable, since com.obsproject.Studio is not a name anybody wants to pick from a
list. An indented line belongs to the entry above it, which is how OBS carries
its plugins as one thing to tick rather than seventeen -- they are extensions of
the flatpak, useless alone.

That is also why creative moved from dnf to Flathub: the plugins attach only to
the flatpak, so the dnf build cannot have them. The rest of the category
followed rather than leave one machine with GIMP from dnf and its neighbour from
Flathub.

The contract now reads the catalog through the same parser instead of keeping a
third idea of the format, and checks the two things this syntax can break
silently: a label leaking into an install command, and a bundle that installs
the application without its plugins. It caught a typo in the Pixelorama id on
the first run.

It also got slow enough to be worth fixing -- fifty-one names, each its own
network call. One bulk query per manager took it from minutes to four seconds.
That query needs `flatpak remote-ls --all`: without it, end-of-life applications
are hidden and read as missing, which reported yuzu as gone from Flathub when it
installs perfectly well.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 23:58:36 -04:00
Gabriel Brown 48f7c1e962 Name the Gaming page, and stop the docs inventing names
The settings reference is generated, and a contract already fails when the
committed copy is stale -- so it was current. It was also wrong: the Gaming page
was documented as "Found on **gaming**" while every other group named a real
page, because a routed page with no entry in PAGE_TITLES fell back to printing
its own id.

The staleness contract could not see it. Regenerating reproduced the same wrong
file, so the copy was current and wrong at the same time -- a check that compares
output against itself cannot catch a generator that is confidently mistaken.

So the fallback is gone. A routed page with no title now refuses to render and
says which page needs one, which is what makes the next page added here
impossible to miss.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 23:32:46 -04:00
Gabriel Brown f457c1eb9f Build the two applications nobody packages, on purpose rather than in passing
Claude Desktop and ChatGPT Desktop ship for macOS and Windows. The Linux path for
both is a community wrapper that converts the official build into an RPM -- so
what lands is still a package dnf owns and can remove, which is the part of the
dnf/flatpak rule that actually matters. What they need an exception for is the
build itself, and there is no packaged form to prefer over it.

`panama app` builds one by name, and is deliberately not part of ./install. A
source build is slow, wants the network throughout, and depends on an upstream
that moves -- twenty minutes in, an error, with nobody at the keyboard, which is
the exact failure the interview exists to prevent. Asking for one is something
you do on purpose, and it is also the rebuild path when a new version ships.

Nothing is pinned. Each build takes the current default branch and the current
upstream release, and reports a failure rather than working around it, leaving
the tree where the error can be read. sunhat pinned versions and every pin was a
404 within a release cycle.

Adding one is adding a file to setup/apps/, and the file has to say why the
exception exists -- the contract fails a definition that does not, because the
guard against this list growing by habit is having to write the reason down.
sunhat had seventy-odd installers and a reason recorded for none of them.

The contract had a bug worth recording: `while read` on the right of a pipe runs
in a subshell, so two of its three per-definition checks recorded findings into
an array that went out of scope at the end of the loop. It reported PASS on a
definition with no description and no build function. Found by standing one in
deliberately and noticing only the third check spoke up.

Also: nautilus-open-any-terminal is now declared, and Panama's copy of the
extension is gone. Fedora packages that extension AND its gsettings schema, and
Panama shipped its own fork of the .py over the same path while declaring
neither -- so a fresh machine got an extension whose schema did not exist. It
worked here only because the RPM has been installed since sunhat. The fork was
also 63 lines behind the packaged version, missing its newer Nautilus and Caja
handling.

Auditing the rest of config/copy for the same shape found nothing else: dnf.conf
is a config file its package expects to be replaced, and the GPU udev rules are
Panama's own.

125 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 23:00:21 -04:00
Gabriel Brown d61792433c Merge the Panama roadmap completion work
Six commits, rebased onto main and integrated with where main had moved in the
sixty-two commits since the branch was cut.

What lands: a theme-profile system, so a curated accent and a saved custom theme
become the same kind of record; a dock context menu; contextual Settings routes
from the dock, notifications and the OSD; overview-thumbnail capture reliability;
and wallpaper and lock-screen hardening -- which needed the shipped wallpaper
that the fresh-install work made exist, and was the dependency that kept this
branch parked.

Where main had moved further along the same path, main won: the focused border
driven by the chosen accent, the gradient built through the shared serializer,
the multi-edge dock. The branch's work sits on top of those. The one thing that
had to be carried across by hand was the `gnome` member on each accent, which is
what recolors libadwaita applications; it moved into the curated table rather
than being lost with the table it replaced.

One behaviour change worth knowing: the OSD now accepts pointer input, where it
previously accepted none. A secondary click on a visible OSD opens its settings,
and a Wayland region cannot admit one button and refuse another. The region stays
bounded to the OSD's own card.

124 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 22:36:41 -04:00
Gabriel Brown 6016efa436 Meet main where it has moved since this branch was written
The rebase itself is the previous five commits replayed onto main; this is what
they needed once they landed there, kept separate so the replay stays readable.

The shell did not start. A Column in this branch's notification settings menu
assigned its own implicitWidth and implicitHeight, which a Qt 6 positioner
computes and does not let you set. That took out every contract that launches a
shell -- six of the seven failures were this one line, and none of them said so
until the error was read to the bottom. A Column already measures itself from its
children, so the bindings are simply gone.

Three assertions pinned an implementation main has since replaced, and each is
updated to pin the intent rather than the mechanism:

  - The display picker now reads primaryFirstMonitors, which is monitors sorted
    with the primary first. Still populated from what is connected, which is what
    the contract is for; the sorted list is the point, so the picker opens on the
    display somebody is most likely to mean. This branch made that change and
    broke its own contract without noticing.
  - The accent swatches come from the accentName schema rather than
    Object.keys(Theme.accents). Same swatches, same order, one source shared with
    every other enum row.
  - The OSD used to take no pointer input at all. It takes some now, because this
    branch's own design calls for a secondary click on a visible OSD to open its
    settings, and a Wayland input region cannot admit one button and refuse
    another. The rule that survives is that the region stays bounded to the OSD's
    own card: it floats over other windows for a couple of seconds, and a region
    bigger than the card would swallow clicks meant for something underneath.

Theme's accent table moved to ThemeProfiles, which is this branch's point -- a
curated accent and a custom profile become the same kind of record. main had
meanwhile given each accent a `gnome` member, the nearest name in GNOME's fixed
accent-color enum, which is what makes libadwaita applications recolor instead of
staying in GNOME blue. That member moved into the curated table rather than being
dropped, and adwaita-accent-contract now reads it where it lives.

Where main had simply moved further along the same path, main won: the focused
border driven by the chosen accent rather than a hardcoded pair, the gradient
built through the shared serializer rather than a hand-rolled string, the
multi-edge dock geometry. This branch's context menu, keyboard focus and
accessibility work sit on top of those rather than beside them.

Three new contracts arrived carrying .sh and lost it, along with the references
in this branch's own plan.

124 contracts pass.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 22:23:27 -04:00
Gabriel Brown ed428e87c4 Complete the Panama theme system 2026-08-20 22:03:08 -04:00
Gabriel Brown 69ecec7ecf Complete contextual desktop controls 2026-08-20 22:00:53 -04:00
Gabriel Brown a90f6eb357 Fix wallpaper scan cap exit status 2026-08-20 22:00:26 -04:00
Gabriel Brown c4642919f7 Close desktop safety gaps 2026-08-20 22:00:26 -04:00
Gabriel Brown c4733e0624 Plan Panama roadmap completion 2026-08-20 21:58:25 -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 47f29f9fa9 Stop describing a desktop this repository does not install
Phase 5. The README advertised two desktops that coexist -- GNOME with Forge,
Dash-to-Dock, Openbar and Vitals, alongside Hyprland -- and nothing in setup/
installed or configured any of the first one. Panama configures one desktop, and
now says so.

config/dot/forge is deleted along with its entry in link-dotfiles. It was the
hedge from when the GNOME session was still the fallback and Hyprland was being
built beside it; the hedge has been paid off. Nine files, six of which were
Forge's own editor backups that should never have been committed.

Searching for the rest of GNOME found nothing else to cut, which is recorded in
the spec so nobody goes looking again. change-settings never enabled an
extension. The mentions of Dash-to-Dock, Openbar and Vitals through the shell are
comments saying what a component was modelled on -- which intellihide behaviour
the dock reproduces, where a colour came from -- and DESKTOP-PARITY.md is the
table of what replaced what. That is provenance, and it is the reason those
components behave the way they do. Vitals in services/ is Panama's own bar
service and merely shares a name with the extension it replaced.

The handoff panel list was wrong in two places. It named Wacom, which nothing
hands off to, and Region, which is a subpage of System rather than a panel. The
nine real ones are read off the call sites and the allow-list that gates them.
The spec said it, the comment on gnome-control-center repeated it, and the README
would have made it three.

One test gap turned up and is closed. The assets contract caught a directory
under config/dot/ that nothing links, but not the inverse: a name left in the
dirs array with nothing behind it, which makes link-dotfiles point ~/.config/<name>
at a path that does not exist. Deleting Forge is the exact move that introduces
that, and nothing would have failed if the array entry had been left. A dangling
symlink is worse than a missing one, because everything that looks there finds
something. Verified by putting the entry back and watching it fail.

The audit of docs/settings.md this phase asked for needed nothing: it is generated
from PreferenceSchema.qml, a contract already fails when it goes stale, and it
carries no claim about GNOME or Forge. The README gained the section it was
missing instead -- the 121 contracts under tests/ were the main safety net in this
repository and went entirely unmentioned in it.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:33:57 -04:00
Gabriel Brown 88497826ec Let a machine say what it is for, and give Firefox its face back
Phase 4: the optional application categories, and the Firefox chrome.

Everything Panama installed until now was what every machine gets, which meant a
work laptop acquired emulators and a desktop that wanted Steam had to be told
about it by hand. The interview now offers the categories in
setup/packages/extras/ as a checklist -- gaming, creative, communication,
virtualization -- and nothing is preselected, because a default here installs
applications nobody chose on a machine whose owner answered a question they
thought was about something else.

A category is one file, and a category mixes both package managers because the
applications do: Steam is in RPM Fusion, Slack publishes only a flatpak. So a
bare line is a dnf package and a flatpak: line is a Flathub ID, and one file
holds the whole answer rather than splitting each category across two. The menu
is read from the directory rather than written down, so adding a category is
adding a file. Every name in all four was resolved against the actual
repositories before being written down, and the contract re-resolves them --
the point of admitting applications one at a time is that they stay installable,
and a typo here fails on somebody else's machine, not this one.

Firefox is declared, and its chrome is Edge-Frfox, vendored into config/firefox.
sunhat carried that theme with no license and no attribution; it is MIT, and now
it says so and says whose it is.

It is the only piece of Panama's configuration that does not go to a path this
repository chooses. Firefox owns the profile directory, names it with a random
salt, and does not create one until the browser has been run -- so link-dotfiles
finds or creates a profile and links both halves into it. Both, or neither works:
chrome/ is the CSS and user.js sets the preference that makes Firefox read chrome/
at all, without which the theme is a directory of dead files.

Two assumptions there were wrong, and the contract exists for both. Firefox has
moved to the XDG directories -- the profile root is ~/.config/mozilla/firefox on
this build, not ~/.mozilla/firefox, and writing to the wrong one themes nothing
and says nothing about it. And -CreateProfile turns out to be non-interactive, so
a fresh machine gets the theme on the first install rather than the second. The
contract runs link-dotfiles for real against a throwaway home with no profile in
it and looks at what came out; it was checked by pointing the search at the
legacy path only and watching it fail.

Also: the enrolment/enrollment spellings from the last commit are corrected. This
repository is US-spelled everywhere else -- color 1131 times against colour never
-- and consistency in prose is worth as much as it is in code.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:24:15 -04:00
Gabriel Brown b319d1a5e1 Stop handing dnf the comments that explain the package lists
Every list in setup/packages/ is annotated -- which package exists for which
settings page, why an exception was made -- and install-packages passed the whole
file to dnf, comment lines included.

dnf does not ignore an argument it cannot match. It reports "No match for
argument: #" and exits 1, and with set -euo pipefail at the top of that script
the first annotated list ends the stage. initial-packages carries four comments
and is the first list read, so a fresh machine got the repositories, the group
updates, and then nothing.

Two things hid it. On a machine that already has everything, a re-run matches
every real name and fails only on the comments, so the failure looks like noise
rather than the stage dying. And every contract that reads these lists strips
comments with sed before comparing -- the tests were reading a file the installer
was not, which is why a repository with a dependency contract, an assets contract
and a doctor still reported PASS across the board.

The fix is one filter used at all five call sites. The contract lifts that filter
out of the script and runs it, rather than describing what it should do, so
deleting or renaming it fails here instead of passing quietly. It also checks the
inverse -- that stripping comments does not strip packages -- because trading a
loud failure for a silent one would be worse than the bug.

Found while adding the extras lists for phase 4, which are annotated the same way
and would have hit the same wall.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 21:11:44 -04:00
Gabriel Brown 13f3648e4d Install the driver, enrol the key, and still never ask twice
Phase 3 of the fresh-install spec: the parts of a run that depend on what the
machine actually is. NVIDIA, Secure Boot, Fedora's preinstalled extras, firmware.

Two of these looked like they would force a compromise, and neither did.

sunhat opened an editor in the middle of its run so grub could be hand-corrected,
and that single step is why walking away from an install did not work. The step
existed to delete duplicated kernel arguments -- and grubby replaces an argument
that already exists rather than appending a second copy, so the duplicates cannot
accumulate and there is nothing to correct. The editor was load-bearing for a
problem that a different tool does not have.

MOK enrolment needs a password now and the same password at the next boot's blue
screen, which reads like a prompt that has to happen mid-run. mokutil has
--generate-hash and --hash-file for exactly this: the interview asks, hashes it
on the spot, and records only the hash. The plaintext never reaches the answers
file, the environment, or a command line, and the stage runs without asking.

The stage runs last rather than fourth as the spec's table had it. The constraint
was always "late" and fourth of eight is not late: enrolment arms a prompt for the
next boot and firmware may want a reboot, so a machine that reboots out of this
stage should already be completely configured.

Every question names what was found -- the card, the packages actually installed
-- and is not asked at all on a machine it would do nothing to. sunhat's debloat
list no longer describes Fedora 44: totem became showtime and LibreOffice is not
preinstalled, so the list is curated and a package that is not installed is never
passed to dnf, which is what lets it outlive a release.

This stage cannot be verified by running it. It installs a proprietary driver and
queues a Secure Boot enrolment, and this machine is an AMD desktop. So every
privileged command is stood in on PATH and the contract asserts which answer led
to which call: that no answers means no commands, that a failed driver install is
not followed by arguments and services for a driver that is not there, that the
hash reaches mokutil through a file and never a command line, and that removal is
offered only for packages that are installed. The contract was checked by breaking
the stage three ways and confirming it caught each. It does not verify that
akmod-nvidia builds, and says so where a reader would otherwise assume it did.

The README's stage table listed three of seven stages; the interview and identity
work never reached it. Corrected rather than extended, since a table that lists
three of seven is worse than one that lists none. The Desktops section still
describes a GNOME session nothing installs -- that is phase 5.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 19:36:53 -04:00
Gabriel Brown 359fb922aa Install the four applications this desktop assumed you had
Helium was already declared. Podman Desktop is on Flathub, so it joins the
flatpak list beside the podman it fronts.

RustDesk was the interesting one. panama-doctor has checked `rustdesk.service`
for as long as it has existed, and autostart.lua works around the tray that
service spawns -- so RustDesk was already part of this desktop, installed by
nothing. The flatpak cannot register a root-owned system service, so unattended
access needs the RPM.

Claude Code has no RPM and no flatpak either, so it takes the official installer
and keeps itself updated afterwards.

Neither pins a version. sunhat pinned upscayl 2.11.5, LACT 0.5.4 and a fedora-40
RPM, and every one of those was a 404 within a release cycle; the RustDesk URL is
resolved from whatever the latest release happens to be. Both are skipped when
already present, and a failure is logged and stepped over rather than aborting a
stage that has already installed the desktop.

That leaves three exceptions to the dnf-or-flatpak rule, all named in one place
with a reason each. The dependency contract now knows they are installed out of
band, so probing for them with `command -v` is not read as an undeclared
dependency -- narrowly, per command, so a genuine omission still fails.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 19:25:30 -04:00
Gabriel Brown b6448c9876 Give the revealed Dock back the pointer that revealed it
The bottom dock came up when the cursor reached the edge and hid again a
quarter-second later with the cursor still sitting there.

The input region has two shapes: a three-pixel strip along the whole edge while
hidden, and a region over the body once revealed. Teaching the dock about left
and right rewrote both, and the bottom case was folded into the branch that
serves a left dock -- x 0. That is right for a dock that hugs the left edge and
wrong for one that is centred on the bottom: the region landed on the left third
of the screen while the pointer that summoned the dock was in the middle. Hover
dropped on the very frame the dock arrived, and the hide timer did the rest.
Approaching from the far left worked, which is the only reason it looked
intermittent rather than broken.

Bottom is centred, so the region starts where the body starts.

Nothing measured the input region, which is why "bottom is unchanged" passed
while bottom was broken -- the contract read the window and the window was
fine. It now probes the mask in both states on all three edges and asserts the
point a hand actually aims at, the middle of the edge the dock lives on, is
still inside the region after the dock arrives. It fails on the old binding
with the coordinates that were wrong.

Claude-Session: https://claude.ai/code/session_01NvgBuSWB5sE43yWmg21ozj
2026-08-20 19:23:02 -04:00
Gabriel Brown 15d54b16f6 Ask everything first, then run without needing anybody
sunhat's failure mode was a question twenty minutes into a run. Walking away
from an install meant coming back to a prompt that had been waiting an hour.

So the questions move to the front. A new interview stage asks what Panama needs
to be told -- hostname, git identity, whether to sign in to GitHub, whether to
make an SSH key -- shows the answers back, and asks once to proceed. After that
nothing asks again. gum is bootstrapped before it runs, because the interview is
built on gum and gum arrives with a stage that has not run yet.

Answers reach the stages through a mktemp file that install sources and the
existing trap deletes, since a child process cannot export into its siblings.
They are not remembered between runs: there is no state file to go stale, and
one of the answers is an email address.

The interview asks only what a stage in this repository actually consumes.
Extras, hardware and debloat questions arrive with the stages that act on them
-- a prompt whose answer nothing reads is a control that lies. The new contract
pins that in both directions, and four deliberate mutations confirmed it catches
a question nobody reads, a stage reading something nobody asks, an answers file
left on disk, and a declined interview that fails to stop the run.

The run now ends with panama-doctor, because a failed-stage count says nothing
about a service that did not start. It never changes the exit code: on a fresh
machine, unconfigured is the honest answer, not a failure.

espanso and oh-my-posh stop being exceptions -- Terra packages espanso-wayland
and Fedora packages oh-my-posh, so the curl installer is gone. bun is now the
only remaining one.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 19:22:36 -04:00
Gabriel Brown 96e4085919 Install the desktop this repository already describes
The shell named a font, a pointer theme and a wallpaper that no package list
installed and no stage placed. It went unnoticed because this machine collected
all three under sunhat and never lost them; a fresh Fedora box would have come
up with tofu for every shell glyph, the default pointer, and no wallpaper --
while Wallpaper.qml called that missing file `shippedPath`.

The dependency contract reported PASS throughout, because it reads commands that
scripts invoke and none of these are one. The new assets contract covers what it
structurally cannot: fonts and pointer themes named in configs and gsettings, a
shipped wallpaper that must exist, commands launched from QML, and directories
nothing puts into service. Written against the broken tree first, where it found
ten faults.

Four of those were packages nobody had noticed were missing -- gnome-calendar,
podman, pipewire-utils and flatpak -- alongside gnome-control-center, which backs
fifteen rows of Panama's own Settings and is commented so it is not mistaken for
GNOME-session residue later.

Fonts turned out to need no install stage at all. Terra, which install-packages
already enables, packages every Nerd Font, so sunhat's wget-and-unzip is replaced
by five lines in a package list. The pointer theme had no such luck: it is
packaged nowhere, so it is vendored rather than downloaded from a URL that can
rot.

espanso stays undeclared. It is in no enabled repository, and building it from
source is the install method this repository is trying to stop using.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 18:57:47 -04:00
Gabriel Brown 6bcc351e07 Write down what a fresh install actually lacks
Panama installs a desktop it does not fully provision. The shell names fonts
nothing installs, a cursor theme nothing installs, and a wallpaper that does not
exist; five packages that settings pages depend on are declared nowhere. None of
it shows here, because this machine accumulated those pieces under sunhat and
never lost them.

The spec covers closing that gap, moving every question the installer needs to
the front of the run, and porting the parts of sunhat that still matter.

Claude-Session: https://claude.ai/code/session_01Q84axqUE5inJhf5Jz9CFy1
2026-08-20 18:38:50 -04:00
Gabriel Brown 3b01f1e020 Let the Dock choose an edge, choose its screens, and be dragged into order
Three things that were parked, and the reasons they were parked turned out to be
the useful part of doing them.

The Dock can sit on the left or the right as well as the bottom. Everything that
assumed the bottom edge is now asked which edge it is on: the anchors, the axis
that gets an implicit size, the sliver of input region that survives hiding, the
direction the body slides away in, and which side a tooltip opens towards. The
body was a Row and is a Grid, because one declaration then serves both
orientations -- Row and Column would each need their own children, and the
cross-axis anchors that centre items in a Row are the wrong axis in a Column.

Bottom is unchanged in every particular, and the settings default to it, so a
hot reload in the middle of this work left the running dock exactly where it
was.

One bug worth recording because static review would never have found it: a dock
spans the edge it lives on, which means anchoring BOTH ends of that edge. The
first side dock anchored top and left only, was free to collapse to its implicit
height, and came out one pixel tall. It parsed, it loaded, and it rendered
nothing. The contract measures the geometry rather than reading the source for
that reason, and was verified by putting the single-ended anchor back.

Per-screen is a list of names where empty means every screen, because a list is
what goes stale when a display is unplugged and "all" should not be spelled as
one. Turning off the last screen collapses to "all" rather than leaving no dock
anywhere and no obvious way back.

Pins can be dragged by a grip. The objection this file recorded for a long time
was real -- dragging inside a Flickable inside a scrolling page fails in a way
that reads as breakage -- and the answer is preventStealing on the grip, so the
page cannot claim a gesture that started there. The arrow buttons stay: they are
the keyboard-reachable path and a grip is not. The order is held locally during
the drag and written once on release, rather than rewriting settings.json for
every slot crossed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 14:10:22 -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 495fb9b41b Give each online account its provider's icon
Four accounts distinguished only by a line of small grey text are four rows that
have to be read rather than recognised. GOA already knows what each one is.

It hands back a serialised GThemedIcon -- ". GThemedIcon goa-account-google
goa-account goa …" -- a preference-ordered fallback chain. The helper passes the
whole chain on rather than resolving it, because which of those names exists is
a property of the icon theme in use and not something a python script talking to
D-Bus should be deciding.

The page walks the chain and takes the first name the active theme actually has.
Both simpler readings were wrong and looked right: taking the first name blindly
assumes it resolves, and taking the last as a fallback assumes the most generic
name is the most likely to exist. On Adwaita the tails of these very chains --
"mail", "goa-symbolic" -- do not exist at all, so a miss would have drawn
nothing. Checked by asking Quickshell.iconPath directly, which returns empty for
a name the theme lacks; the fallback is avatar-default-symbolic, which is
present.

SettingsCard grew an optional icon for this. It is empty by default and the
header lays out exactly as before when unset, so no other card moves.

Last-sync is not here because there is nothing to show: GOA exposes no
sync-related property at all, on any of these accounts. Better to say so than to
invent a timestamp from when the page last refreshed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 13:28:05 -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 4cbab01ae2 Show what has actually been installed
Automatic updates leave no other trace. The Flatpak that sat here as "1 update
available" installed itself at 00:14 this morning and nothing on the machine
would have said so.

Both sources are asked in their own machine-readable form and merged on time, so
the answer reads as one history rather than two lists to interleave by eye.

Two parsing traps worth recording next to the code. flatpak's --json prints
timestamps as "Aug 20 08:07:46" with no year in them, so the year is inferred
and a date that would land in the future is read as last year's. And dnf5's
start_time is epoch UTC while its own history table prints that same value as
though it were local -- checked against rpm, and the local rendering here is the
correct one.

The contract asserts entries are newest first, that none is dated in the future,
and that both sources parse; it was verified to fail by breaking the year
inference so every flatpak entry landed tomorrow.

Loaded on demand rather than with the page, because it reads both full
transaction logs.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:30:31 -04:00
Gabriel Brown de45f205ad Carry settings between machines by allow-list, not by stripping
panama-settings-backup already snapshots this machine so it can be put back
exactly as it was, arrangement and all. This is the other thing: an export meant
to travel, carrying the preferences that describe taste rather than hardware.

The export is an allow-list read from the preference schema rather than a
deny-list of things to remove. A key added later that happens to hold a token
cannot leak into a file somebody emails to themselves; being wrong in this
direction loses a setting, being wrong the other way publishes a secret. It
earned that immediately -- this machine's store holds an orphaned shadowOffset
from a setting that no longer exists anywhere in the source, and it was left
behind without anyone having to know about it.

Three settings stay: the display arrangement, which is keyed by output names
that mean nothing elsewhere; the last page opened, which is session noise; and
schemaVersion, which belongs to the store rather than to a person. Import is a
merge, so settings a file does not mention are left alone, and it is idempotent.

Two bugs made and caught here, in opposite directions. Validation missed 36
settings because "real" was spelled "float" and enums fell through entirely, so
an out-of-range or nonsense value would have been written straight into the
store. Correcting that then broke numeric enums -- vrrPolicy is an enum of 0..3
and the options were read with a regex that only matched quoted values, so those
settings had no known choices, were declared unverifiable and were refused:
valid settings dropped silently in transit.

The contract could not see the second one. It checked only that bad values are
refused, and when numeric enums were unreadable they never reached the bundle at
all, so every "did it arrive" assertion was satisfied by their absence. It now
requires the export to carry what it should as well as withhold what it should
not, and was verified to fail in both directions.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 11:02:42 -04:00
Gabriel Brown 52e2a83a78 Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this
machine has met, with a two-press forget for a host whose key has changed.

Nothing here reads private key material. Fingerprints and comments come from the
.pub file, and "does this key need a passphrase" is answered by asking
ssh-keygen to derive the public half with an empty one -- it succeeds for an
unencrypted key and fails for an encrypted one, and either way the only thing it
can emit is public. The contract checks that against the payload that actually
reaches the page rather than against the source, because what the code intends
and what it ships are different claims.

Unloading a key from the agent is refused, with its reason. On this desktop
`ssh-add -d` prints "Identity removed" and the key is still offered a second
later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed
one comes straight back off disk. That was measured rather than assumed -- a
plain ssh-agent removes durably, this one does not -- and a button reporting
success while changing nothing is worse than no button. The page says so and
names the thing that does work: move the file out of ~/.ssh.

SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no
agent" while one is plainly running. The helper falls back to the keyring
socket, and an agent started by hand still wins. That gap is the same one that
made reaching these servers awkward in the first place.

Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen
without going somewhere it should not -- -N puts it in argv, which every process
on the machine can read -- and driving the prompt over a pty did not work.
Offering to generate an unencrypted key instead would be a downgrade dressed as
a feature, so the page does not offer to generate at all.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 10:17:33 -04:00
Gabriel Brown 79b3d5cb85 Close the sweep's last blind spot, and stop shortcuts silently colliding
gapsIn and gapsOut were the only two compositor settings the write sweep had
never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the
sweep had no way to compare that. The preference behind each is a single int
that Hyprland expands to four sides, so a uniform reading compares exactly. A
non-uniform one is not something the preference can express, and is skipped
rather than collapsed to a number it never wrote. 63 of 63 verified live now,
none skipped.

Wallpaper thumbnails are cached. The report that five of them sat at "Loading…"
was a screenshot taken 1.1 seconds after the page opened -- decoding one of
these at tile size takes between 1.2 and 2.6 seconds and about ten start at
once, which the code already said. Measuring it did turn up something real
though: without a cache, scrolling back up pays that decode again for every
tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail
until restart, which is worth it for a directory of files that are added rather
than edited.

A chord already in use is now named rather than taken: "Super+Q is already
Terminal". Two actions on one chord means whichever Hyprland reads last wins,
which is not a thing to find out later by pressing it. Rebinding a shortcut to
the chord it already holds is correctly not a conflict.

Also: Open Appearance lands on the Windows tab now that the page has tabs,
Storage points at reclaimable container space, and a dock row shows its desktop
id only when two pinned applications share a name -- it is developer text, and
repeating it under fifteen recognisable names made the list harder to scan.

Written down because it cost the shell: QML has no default parameter values, and
`function openSettings(page: string, section: string = "")` fails the entire
configuration rather than the one function -- so the bar and dock went with it,
and 43 contracts failed at once pointing at the same line. qmllint --bare passes
that, which is why the usual check before touching the running shell did not
catch it. openSettingsSection exists as a separate function for that reason.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:56:02 -04:00
Gabriel Brown 1b845c0126 Rebuild Home around what this desktop already knows
Home led with a diagram of the attached monitor -- DP-2, 4500 x 3000, 1.13x
scale, XRGB2101010 -- which is Displays-page data, was the largest thing on the
screen, and has never been needed there. Under it sat a permanently open search
field for a weather location that is set about once a year, and then roughly
half a page of nothing.

It now opens with the two or three things somebody would open Home to do:
starting or ending a focus session, Do Not Disturb, and what is running. The Do
Not Disturb switch is disabled and says why while a mode is holding it, so it
cannot appear to be a control that is being ignored.

Findings sit above a reassurance line, the same shape the Firewall and
Containers pages use. Each finding names the page that can resolve it, because a
home page reporting a problem it cannot help with is only an alarm. On this
machine that is one finding today -- PostgreSQL and Redis reachable from the
network -- above "27 health checks pass, 522 GB free, snapshots ran at 02:00".
"Do next" appears only when there is something in it.

Weather stays, as the greeting's second line rather than a card with a search
box open, and its picker is collapsed behind the current location.

FocusModes was missing `import qs.config`, so DesktopPreferences was undefined
and the modes list came back undefined with it -- shipped two commits ago with
nothing noticing, because until Home referenced the service no isolated shell
had ever instantiated it. Focus modes would have quietly had no modes. Pulling
these services together on one page is what surfaced it.

LocationPicker gained a picked signal, the same way DisplayModePicker did, so a
container can put the search away without reaching into it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 09:15:01 -04:00
Gabriel Brown f53ca16392 Make the focus exception list real, or it was a page telling a lie
The mode data model shipped with an allow list and nothing that read it. The
summary would say "2 apps may interrupt" while notification delivery never
consulted the list and no editor could set it. That is the dead row this work
has spent its time removing, introduced by the work itself.

The banner gate consults the mode in force now, and the list can be edited from
the applications that have actually sent a notification -- an exception for
something that never notifies is not a choice worth offering.

Exceptions belong to a mode. allowedApps is empty whenever no mode is active, so
a Do Not Disturb switched on by hand stays absolute and nothing can leak into
it. That scoping is asserted, not just written.

Verifying this took three attempts, and the second was a real defect in the
guard rather than in the code. The contract grep for FocusModes.allows matched
the comment that explains it, so the check passed with the enforcement deleted.
It matches the gate expression now. A guard a comment can satisfy is not a
guard, and this is the third time prose has satisfied one here.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:23:30 -04:00
Gabriel Brown 6dc606b872 Give focus modes conditions rather than alarms, and let Gaming hand over
A mode is on because something is true right now: a game is running, a window is
fullscreen on a given display, a workspace is focused, the clock is inside a
window. That is asked again rather than fired once, and it is the whole reason
schedules could be included here without the usual failure modes. A machine
asleep at 23:30, rebooted at 02:00, or opened at 08:00 into a window that has
already passed all reach the right answer by being asked again; an alarm gets
all three wrong.

The midnight-crossing rule is the part worth being careful about: a window
belongs to the day it STARTS on, so a Friday-only 23:30-07:00 covers Saturday
morning and must not cover Saturday night. That arithmetic was tested as pure
logic before anything was built on it, including every malformed input failing
closed -- silencing someone because a time string was wrong is the worst way
this could fail.

This does not take over the manual timed session. FocusSession already owns
that, with its capsule, shortcut, Quick Settings entry and contracts, so modes
defer entirely while one runs. Two writers of Do Not Disturb would each restore
whatever the other happened to leave behind.

Gaming hands over rather than being duplicated. The hook was silencing
notifications itself, which would have made exactly those two owners -- and
Gaming.active only polls while its settings page is open, so a mode could not
have seen a game reliably in any case. The hook reports the game over IPC now
and the mode decides what that means, the Gaming page points at it, and
gamingSilenceNotifications is retired from the schema, since a setting nothing
reads is the dead row this work keeps removing.

Sleep ships disabled. A desktop that starts silencing someone on first boot has
overstepped, whatever the default hour.

Three contracts moved with it. gaming-contract asserted the hook uses setDnd,
which was right before and wrong now; the shell-side assertions that setDnd and
dndState exist stay, because a toggle would flip an already-silent machine back
on. The new contract is proven to fail by breaking the midnight rule and by
letting modes run alongside a manual session.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 02:06:50 -04:00
Gabriel Brown 32fab59d24 Let a sound device be heard, and the Dock's icons be sized
Nine outputs named after their chipsets cannot be told apart by reading, so each
one gets a Test button that plays a short sample out of that device. Targeted by
node name rather than by making it the default first, because finding out which
is which should not move where everything else is playing.

That belongs in its own service rather than in AudioDevices. sound-page-contract
forbids Process, pactl and wpctl in the files that own device state, and it is
right to: shelling out there races the PipeWire service that owns those same
objects. Playback is a different thing -- pw-play opens its own stream and
mutates no device, so there is nothing to race -- but the rule's letter covered
it, and weakening a guard to fit a new case is how guards stop meaning anything.
SoundTest exists so AudioDevices stays native bindings only.

Worth recording next to the call: pw-play falls back to the default output for a
target it cannot find, rather than failing. A stale node name would play from
the wrong device and look exactly like a successful test, which is why the name
is taken straight from the live node.

The Dock's icon size was a constant in Theme. It goes through the preference
schema like everything else, so validation, search, the generated docs and the
write sweep all pick it up without being told about it separately -- and two
contracts duly failed until docs/settings.md and the per-page commands were
regenerated.

Dock position is deliberately not here. It is not a setting but a rework: the
dock is anchored bottom, and the reveal strip, tooltip placement, intellihide
and the qs-dock rule in hypr/rules.lua all assume that. Doing it properly means
changing compositor rules on a machine somebody uses daily, which is not
something to start as a side effect of adding a slider.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 00:03:21 -04:00
Gabriel Brown f6b970da21 Turn the rows that only reported things into controls
Autostart entries showed "Enabled" or "Disabled" as plain text. The row did
toggle on click the whole time, so this is an affordance rather than a missing
capability -- but a control that reads as static text is one nobody knows they
have. It is a switch now, with removal alongside it behind a confirmation:
disabling writes Hidden=true and can be undone, deleting the file cannot.

remove-autostart is confined to files the autostart directory owns. It resolves
the path and compares the parent, so a name like "../../.bashrc" cannot escape,
and it refuses symlinks rather than following them -- deleting through one would
remove whatever it points at, which is somewhere else and not ours. Each refusal
was tested against a fixture directory, including a symlink aimed at
/etc/hostname, which survived.

Sharing says who is signed in from another machine: user, origin and since when.
An empty list on this machine proves nothing, so the parser was checked against
sample `who` output -- it picks out remote sessions and leaves out local seats
and the :0 display, which would otherwise report the person at the keyboard as a
remote login.

Media sharing was "Available" and nothing else: rygel installed, rygel.service
disabled, no way to change that from here. It is a switch now, and it says what
it does before you touch it rather than afterwards -- turning it on publishes
media folders to every device on the network with no password in front of them.

Per-application camera and microphone permissions come from the portal's
permission store, which is where an application that asked through the portal
has its answer recorded. The page states the limit plainly instead of implying a
protection that does not exist: a program installed outside the portal opens the
device directly and nothing here stands in its way. Anything that is not an
explicit "yes" is treated as withheld, because guessing generously about a
camera is the wrong way to be wrong.

The first version of the write silently did nothing -- SetPermission takes an
array of strings and was being handed one string -- and the test did not notice,
because it discarded the helper's output and only checked that state was
unchanged afterwards, which was trivially true. The contract now requires the
value to move, and was proven to fail by putting that exact bug back.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 23:10:48 -04:00
Gabriel Brown 7e1c85b094 Group the long pages by what you are trying to do
Notifications repeated both lock-screen switch labels for every application, so
twenty apps meant sixty rows of the same two sentences and the page could not be
scanned at all. Each app is one row now, carrying what its switches add up to --
"On, lock screen shows the sender only", "On, hidden on the lock screen",
"Notifications off" -- with the switches behind it, one app open at a time. The
identifier only appears while an app is open, which is the only time it
disambiguates anything, and the content switch dims when the app cannot reach
the lock screen at all, because there it means nothing.

Shortcuts were already grouped; the problem was that "Windows" caught focus,
movement, splitting, resizing and window state alike and held 43 of the 93
binds. A section that long is a list, not a grouping. They are separated by
intent now -- Focus, Move & split, Size, Window state -- and the split was
checked against the binds this machine actually has rather than trusted from the
keywords. Order matters in two places worth naming: "Next window splits down" is
about splitting rather than focus, and "Focus session" is quiet mode bound to a
workspace rather than window focus, so both are settled before the general
checks.

Refresh rate gets its own row. That need was created by collapsing the
resolution list: the rates for a resolution were only ever reachable by opening
it, so changing nothing but the rate meant going through the mode you already
had. It appears only when the current resolution offers more than one.

Default-application rows carry a chevron, having previously opened a chooser
while looking completely inert.

The notification contract asserted the literal Notifs.appRule(app.id).enabled,
which moved when the rows collapsed. The rule is still read through a binding on
Notifs.appRule, so a rule changed elsewhere still reaches the row -- the
assertion now requires that, rather than requiring one particular spelling of
it.

The power profile rows were left alone. A three-way choice in three rows looks
wasteful until you notice each row explains what the profile does, and that page
has empty space to spare; a segmented control would trade information for space
that is not scarce.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:54:51 -04:00
Gabriel Brown 0c364f38e6 Stop sending people to GNOME for pages this app already has
Panama absorbed Users, Sharing, Printers and Online Accounts one page at a time.
Each time, the row pointing at GNOME's equivalent stayed exactly where it was --
so an app whose stated purpose is to make GNOME Settings unnecessary shipped four
doors back to it, two of them inside a card headed "these areas remain owned by
Fedora and GNOME's mature system panels".

Nothing failed. Every row worked as written. They were simply no longer true, and
no test could notice, because none of them knew what Panama had come to own in
the meantime. gnome-handoff-contract reads the sidebar for the pages that exist
and the pages for the panels they hand off, and fails on any overlap -- derived
from both sides rather than a hand-kept list, so absorbing the next page cannot
leave a stale door behind. Adding an online account is allow-listed with its
reason: it genuinely requires GOA's own dialog.

health-ui-contract asserted those handoffs were present, which is how they
survived. The assertion is inverted rather than deleted, so reintroducing one
fails loudly.

The Home Assistant "Light entities" box is gone. It was a multi-line list of
comma-separated Zigbee entity IDs, and the light catalog does not come from it --
the helper discovers that live. It is a one-time migration seed for the Control
Center selection, so saving now passes the stored value back untouched: setting a
URL or a token cannot disturb it. Deleting the control naively would have written
an empty list over it.

Sharing showed two "Port" rows for RDP, same label and value, one read-only and
one editable, separated by a switch. The read-only leftover is gone. The SSH port
stays read-only because sshd's port is not ours to write.

About reported "488G free of 1.9T" where Storage said "523 GB free of 2.0 TB" --
the same drive, binary against decimal. About uses decimal now, matching how
drives are sold. Memory and swap stay in GiB, which is how RAM is sold.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:58 -04:00
Gabriel Brown 1aa1324083 Lead Appearance with light and dark, and stop pages listing whole datasets
Appearance was six cards deep and Light/Dark was the third of them, below the
wallpaper grid and the entire lock screen -- so the control reached most often
was the last one you got to. It is five tabs now, Theme first. The mock showed
four; the page turned out to have eleven cards, so Titlebars and Windows became
Windows, and Clock and vitals became Shell, rather than pretending four would
hold them.

Region, Date & Time and Displays each rendered a complete dataset as rows: every
installed locale, the whole tz database, every mode the monitor advertises. The
chooser was never the problem -- SearchPicker already existed and worked. It was
simply rendered always-expanded, so the one line saying what is currently set sat
under hundreds that were not. PickerRow collapses each behind its current value
and closes again once something is picked.

The avatar never appeared to change because accountsservice writes every picture
to the same path, leaving the URL byte-identical while Qt served its cached
image. cache:false was already set and could not have helped: an unchanged source
is never re-read at all. avatarUrl now carries a revision fragment, bumped only
when a write actually succeeds. Pictures are cropped before they are set, in the
picture's own pixel coordinates so the result does not depend on the size it
happened to be displayed at, and written out at 512x512 through GdkPixbuf --
already a dependency here, so nothing new is required.

Snapshots listed nothing. The timeline and its Delete buttons existed the whole
time, behind a row labelled "Browse...", a word that promises a file browser. The
three most recent points are shown inline now, with the rest one press away.

qmldir-registration-contract exists because an unregistered component is not a
quiet problem: Quickshell fails the entire configuration on it, so the settings
window dies and the bar and dock go with it. That happened twice while writing
this, both times on a machine somebody was using. It is pure file inspection, so
it runs before a change ever reaches the running shell.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 22:36:41 -04:00
Gabriel Brown ac5e6e2130 Add a Containers page, grouped by project and led by what is exposed
Every container on this machine is created by rootless podman-compose and
labelled with the project it belongs to, so the grouping is read from the
labels rather than invented. State then decides prominence within that
grouping -- running containers get rows, stopped ones collapse to a line --
which is why neither axis had to be chosen over the other.

Acting on a stack uses plain podman over the labelled set, never
`podman-compose down`, which would remove containers this shell did not
create. The compose file is the source of truth for what exists and belongs
to the repository. Nothing here needs privilege.

The findings on top are the crossing the Firewall page reports, seen from the
side that can close it: the firewall knows only that something is listening,
while this page knows which container, which compose file, and which token is
missing from it. So `bind-local` prepends a loopback address and leaves the
line byte-for-byte -- variables, quoting and style intact -- then re-parses and
rolls back unless exactly those ports moved. It refuses anything ambiguous
rather than guessing. Rewriting the mapping to the port podman reports today
would have deleted the ${POSTGRES_PORT} indirection that makes it
configurable at all.

Unused volumes are read from podman's own dangling filter. The first version
used MountCount, which is a runtime lock counter and not a usage signal: it
reads zero for a volume a running container has mounted this second, so
"remove unused volumes" offered to delete the live Command Center database.
The cross-check against `podman system df` is what exposed it. The contract
reintroduces that bug deliberately and fails if the guard does not catch it,
because a guard nobody has seen fail proves nothing.

Every mutation in the contract runs against a stubbed podman. Nothing in the
suite starts, stops or removes a real container, image or volume.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 20:49:04 -04:00
Gabriel Brown 536958430f Fix the polkit unit's ignored rate limit
systemd logged "Unknown key 'StartLimitIntervalSec'" on every start:
rate limiting belongs in [Unit], not [Service], so both keys were
ignored and the restart limit they were meant to impose did not exist.
An agent that failed repeatedly would have flapped rather than stopping.

Found while investigating reported prompt failures, which turned out not
to be a defect: the trace showed both authentications succeeding on the
first attempt. The failures came from testing -- prompts raised by
background pkexec runs and then cancelled seconds later, while someone
was trying to type into them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:54:16 -04:00
Gabriel Brown fd99569666 Add a Firewall page, led by what is actually reachable
Listing zones and services is what firewall-cmd already does. The
question it does not answer needs both halves at once: a port is
reachable only when something is LISTENING on a network address AND the
firewall permits it.

On this machine that crossing is the whole story. The rules look
unremarkable -- one zone, three services, a port range -- and what they
mean is that PostgreSQL and Redis, published by rootless containers on
every interface, are reachable by anyone on the network. Neither half
says that alone, which is exactly how a tidy rules list coexists with an
open database. Nothing was misconfigured: Fedora's default zone met
podman's default publish behaviour.

Ephemeral client sockets are excluded. A browser's outbound UDP port is
indistinguishable from a service in ss, and listing twenty of them
buried the two rows that mattered.

Closing the port range names what it would cut off, by service, before
doing it, and removing ssh says so when someone is connected over it.
Rich rules are shown and never edited: a syntax is not a setting, but
hiding it would misrepresent the configuration.

The contract needed a recorded firewall, and the reason is worth
keeping. The rule this page exists for cannot be tested against this
machine -- its zone permits everything above 1024, so "listening" and
"listening and permitted" give identical answers, and a blocked listener
needs a port below 1024, which needs root. With the crossing deleted,
the contract passed. It now runs against a fixture where two listeners
are blocked, and catches it.

Also here: polkit response files are written 0600 rather than at the
default mask, the agent sweeps requests left by an instance that did not
exit cleanly, and the write sweep waits for its harness to be ready
instead of reporting the startup race as settings that failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:46:16 -04:00
Gabriel Brown a412e3d894 Fix the compositor write contract properly, not by waiting longer
Widening its waits earlier treated the symptom. It still failed about
one run in three, on an idle machine, taking eighteen seconds to do so
-- which was the clue: nothing was in flight to wait for.

SystemSettings verifies each write before storing it and serializes
overlapping ones, so the restore arriving while the previous batch was
still settling was being dropped outright. Waiting longer for a write
that was never queued cannot help. It is issued up to three times now,
and the run where a retry fires visibly takes eight seconds instead of
two.

Retrying cannot hide a broken write path: with the write stubbed out the
contract still reports exactly which policy failed to reach the
compositor.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 19:05:29 -04:00
Gabriel Brown 4cbe3b882a Add a Gaming page, and let the desktop react to games
Live first, because unlike every other page here this one has a live
dimension: card temperature, power draw, whether Game Mode actually
engaged. It polls only while it is open, since a settings page nobody is
looking at has no business waking the CPU.

The part that makes it Panama's page rather than a gamemode config
editor is the hook. gamemode runs a script when a game asks for it and
another when the game exits, so the power profile switches to
performance and notifications go quiet for exactly the duration of a
game -- and afterwards both go back to what they WERE, not to a default.
A Do Not Disturb someone set by hand survives a game; a power profile
someone chose is restored rather than replaced. Verified against real
gamemode activation, not merely by calling the hook.

Two things the page reports rather than hides. Game Mode's headline
trick is switching the CPU governor to performance, and this machine
already runs performance, so it says so instead of implying it helps.
And Proton builds are listed but never chosen: Steam picks the runtime
per game, and a control here would claim an authority this page does not
have.

The hook first called a notifications function that did not exist, and
the one that did was a TOGGLE -- the wrong primitive entirely, since
toggling at game start would unsilence notifications that were already
silent. The shell gained an explicit setter and reader.

search-routing-contract kept its own hand-written list of every page,
which made adding one fail as "not a known page" -- a sixth place to
register a page and a sixth chance to forget. It now derives the mapping
from the shell, which already knows it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:55:10 -04:00
Gabriel Brown edc504af2e Drop the self-hosted integration batch, and say why
The machine runs essentially every popular self-hosted service, and on
looking at them the batch was revised once and then dropped. Almost all
of it is infrastructure for other services rather than anything a
desktop consumes -- whisper there feeds Bazarr subtitles, not dictation
here -- the desktop has the better GPU anyway, and the one genuinely
desktop-shaped idea is already solved by an installed application.

Recorded rather than deleted so it is not proposed again, with the
lesson that matters: a service existing is not a reason to integrate it.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:38:19 -04:00
Gabriel Brown a15f019c17 Offer automatic package downloads, now that the machinery is installed
The page had a row for the case where dnf-automatic is absent and
nothing for the case where it is present, so installing it made the
setting disappear rather than appear.

The switch enables downloading only, which is the shipped default and
the right one to keep: a machine that installs packages unattended can
reboot into a kernel nobody chose. Downloading ahead of time is what
makes the install quick when someone does choose it, and the row says
exactly that rather than implying updates apply themselves.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 18:24:30 -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 b10f8e2593 Stop the compositor write contract failing under load
It passed alone in two seconds and failed in a full suite run at six,
which is the worst way for a test to be wrong: the code was fine and the
report said otherwise.

Each of its waits polls until the compositor reflects a write, and four
seconds was enough on an idle machine and not enough on a busy one. They
are fifteen now. A longer bound cannot mask a dead write, because a
write that never lands never matches -- verified by stubbing the write
path out and watching it still report exactly which policy failed.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:33:12 -04:00
Gabriel Brown 8f0fe23377 Add Software Update, across packages, applications and firmware
Three sources that fail independently, so they are counted and applied
separately: a flatpak mirror being down says nothing about whether a
kernel security fix is waiting. Blending them into one number would hide
exactly the case that matters.

Checking costs about nine seconds, which is too long to spend every time
a page opens, so the page opens on the last result and says when it was
taken. A first visit with nothing cached goes and finds out rather than
showing a confident "up to date" it has no basis for.

Installing packages takes a snapshot first, named after what is about to
happen, so Snapshots shows "before 32 package updates" rather than a
timestamp. Best effort: a machine without snapper still updates, because
an update that refuses to run when a nicety fails would be worse than
one without a restore point.

Automatic updates cover applications only, through a Panama-owned user
timer running daily with a randomized delay. Packages still ask, and
dnf-automatic is reported as absent rather than offered, because
installing software is not a settings action.

Health gained a check, and that is where the bug was: it first returned
status "degraded", which is not in the doctor's vocabulary of ok,
warning, error and unconfigured. It was counted as nothing at all while
the summary still said healthy -- the same silent no-op this codebase
keeps relearning. A contract now asserts every status a check can return
is one the doctor counts, and the doctor's own contract knows about the
new check rather than failing on its arrival.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:25:11 -04:00
Gabriel Brown 89cf0f8c29 Plan the settings work beyond GNOME parity
Parity is done except for Color, which the platform blocks. This is the
other direction: what a settings application should own on a machine
whose owner runs the servers.

Every item was checked against this machine before being written down,
including the one that was dropped -- a VPN page, because the WireGuard
tunnel lives on the router and neither wg nor tailscale is installed
here, so it would have had nothing to drive.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:06:39 -04:00
Gabriel Brown e290a262f3 Make notifications legible, and follow windows that ask for focus
The notification background was a 6% foreground tint over a transparent
window, which meant it had nothing behind it at all: the card sat
directly on whatever was on screen and the text competed with it.
Notifications are the one surface someone reads without having chosen to
look at it, so it now uses a real popover surface -- a little lighter
than one you opened deliberately, because a toast arrives unbidden and
full popover weight reads as a dialog demanding an answer.

Windows that ask for attention are now switched to rather than merely
highlighted, so a link opening on another workspace takes you there.
That setting already existed and was simply off; it is a preference
rather than a hardcoded behaviour, so it can be turned back off on the
Desktop & Dock page.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 17:05:37 -04:00
Gabriel Brown 6997dd535f Add high contrast, and make remote desktop configurable
Two of the three panels still handed to GNOME, having actually checked
each rather than repeating that they were not worth owning.

Universal Access turned out to be mostly ours already: the magnifier,
pointer size, text scale, motion and dimming were all present. High
contrast was the real gap. It reaches GTK4 applications through the
desktop portal, which republishes GNOME's accessibility setting as
org.freedesktop.appearance contrast -- so no high-contrast theme is
involved, and none is installed here. Verified end to end: committing
the preference drove gsettings and the portal reported contrast 1.

Sticky, slow and bounce keys stay absent. There is no Wayland or
Hyprland implementation, and the compositor would store the XKB option
while nothing ever acted on it.

Remote desktop gained port, view-only, and clearing stored credentials.
SETTING credentials opens a terminal running grdctl, which prompts for
the password itself. That is not a hand-off for lack of effort: grdctl
takes the password on a terminal and core-dumps without one, and the
only alternative -- passing it as an argument -- would publish it
through /proc to every process on this machine. Typed into grdctl
directly it never passes through Panama, and a contract now fails if it
ever appears on a command line.

Color stays with GNOME, and not for lack of effort either. colord runs
here with seven profiles and zero devices registered, because the
daemons that register displays do not run under this session, and
Hyprland exposes no ICC, gamma, or color-management option at all. A
Color page could import a profile, attach it to nothing, and change
nothing -- the same failure refused for rollback and printer drivers.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 16:42:20 -04:00
Gabriel Brown 1f40f8e136 Add a snapshot manager, and start covering home
The machine already had snapper running hourly on btrfs, so the tool was
never missing. What was missing is that snapper's only configuration
covered / -- and /home is a separate subvolume with no configuration at
all. Six hundred and forty-three snapshots existed and not one of them
contained a document. Anyone reaching for file history would have found
their system and none of their files.

/home now has a configuration on the same hourly timeline, with
deliberately conservative retention: Steam's 1.2 TB lives on that
subvolume and churns on every game update, so keeping five hourly and
seven daily bounds what those updates can pin.

Per volume, because on this machine "one is covered and the important
one is not" was the news, and a timeline opening on system snapshots
would have buried it. Inside a volume the timeline is the familiar view:
points in time, newest first, each openable as a folder tree to take a
file out of.

Restoring sets the current version aside as .before-restore-N rather
than overwriting it. A restore that destroys the thing you were about to
compare against is how someone loses the work they were trying to save.

Rollback is deliberately absent. snapper's rollback changes the btrfs
default subvolume, and this system's fstab pins subvol= explicitly,
which overrides it -- so a rollback would report success and change
nothing after a reboot. A recovery feature that silently does nothing is
worse than not having one, and making it work means editing fstab and
the bootloader, whose failure cannot be repaired from inside the
desktop.

Per-snapshot size is reported as not measured, because measuring it
needs btrfs quotas that cost performance on every write. Free space is
shown instead, which is the number that decides anything.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 16:11:50 -04:00
Gabriel Brown e9d567aa72 Own printers, driverless only
The fourth panel this desktop handed to GNOME Settings, and the last one
worth owning.

Driverless only, deliberately. Adding a printer that describes its own
capabilities over IPP is supported; choosing a PPD or fetching a vendor
driver is not, and the page says so rather than pretending. That
restraint is the whole design: a wrong driver produces a printer that
accepts jobs, reports success, and prints nothing, which is the worst
failure this page could ship because it looks like it worked. A printer
old enough to need a PPD stays a job for the system printer tool.

Printers and the queue are separate cards because they answer separate
questions. Which printers exist is one; where a document went is the
other, and it is the one that actually brings someone here -- so the
queue is a single list across every printer.

Device URIs are validated by scheme before reaching CUPS, whose backends
run as root. file: and pipe: do not lead to a printer and are refused
here rather than further down.

This machine has no printer, so the page was built against a temporary
CUPS queue that was created, exercised through the service, and removed;
the service was confirmed to observe the removal rather than merely
perform it. Discovery and the driverless add path are verified by their
refusals rather than against hardware.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 15:07:17 -04:00
Gabriel Brown 914d58f52b Record which GNOME panels are owned and which are not
Which panels we own is a question that otherwise gets answered one panel
at a time, and forgotten between them.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 13:17:53 -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 e0c0e53ae0 Say what the sweep helper is, when run on its own
A suite runner walking the tests directory executes it with no arguments
and gets an IndexError, which reads as a failing test rather than a
helper being used wrongly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 12:22:38 -04:00
Gabriel Brown 180308135a Verify every setting actually does something
The audit that followed "some things in the settings app don't work".

Sixty-five settings reach the compositor and five of them were checked
against it. The rest were covered only by tests that read source text,
which is exactly where a dead setting hides: the write no-ops, nothing
fails, nothing logs, and the row simply does nothing.

The sweep drives each setting through SystemSettings.commitPreference --
the entry point a settings row uses -- flips it to a value it does not
hold, reads it back from the live compositor, and puts it straight back
before touching the next one. Settings Panama stores itself get the same
treatment against the store, since a value that fails to persist is the
same dead row from the outside.

Result: 61 of 63 compositor settings verified against the running
compositor, and 51 stored settings round-tripped. No failures. The
breakage was confined to the Applications page, which is fixed.

Proven able to fail before being trusted: with commitPreference stubbed
to return true without applying, 61 settings are reported; with the
store stubbed to return nothing, 51 are. A one-second settle window
keeps a slow read from being reported as a dead write, which it briefly
was.

Also here: control-center-contract asserted the literal margin
expression that made the panel hang 38 pixels below the bar, so the
contract and the code agreed and the bug was invisible to both. And
settings-page-registry-contract is deleted -- settings-nav-contract
already checked those files and more. It would have caught the Storage
page omission if I had run the suite instead of a hand-picked subset.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 12:17:27 -04:00
Gabriel Brown b743f44c5b Make changing a default application actually take effect
Three bugs, all silent, all in the same feature.

The write always worked. What failed was the refresh after it. That
refresh is called from the mutation's own onExited handler and was
guarded on `busy`, a binding over both processes -- and a binding hands
back its cached value until the change notification feeding it has been
delivered, which inside that handler has not happened yet. So `busy`
read true, refresh returned immediately, and the page kept showing the
old application with no error anywhere. Guards now read the Process
objects directly, where the value is current, and a refresh is no longer
blocked by the mutation that asked for it.

The service also kept its own list of which roles it would accept. It
stayed at seven when the helper and the page grew documents, text and
archives, so choosing a PDF viewer set an error and changed nothing.
It is derived from the snapshot now.

And category matching never worked. DesktopEntries returns a QML list,
for which Array.isArray is false, so the code stringified it into
"Network,WebBrowser" and split on ";" alone -- one token matching no
category. Browsers still appeared because their generic name contains
"web browser" and the terms fallback carried the role by itself.
Archives matched nothing at all, so that row could only ever offer the
application it already had.

The harness that should have caught the first bug passed while it was
live: it set each role to the value it already had and asserted no error
appeared, and the bug produces no error. It now changes a role to a
genuinely different application, requires the service to observe the new
value, and changes it back -- with the contract restoring the original
from the outside however the run ends.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:59:53 -04:00
Gabriel Brown 2528edfddc Stop every popover double-counting the bar height
The control center fix applies to five more surfaces: the date menu,
the clipboard panel, the activity panel, notification toasts, and the
signal glass all opened 48 pixels under the bar while their own
expression asked for 12.

wlr-layer-shell has three behaviors and only the middle one is subtle.
A positive exclusiveZone reserves space; a negative one ignores what
others reserved; zero reserves nothing but RESPECTS what others
reserved. Every popover here uses zero, so the compositor had already
placed them below the bar before their own margin applied, and adding
Theme.barHeight counted the bar twice.

It is not a crash or a warning -- the surface simply opens lower than
written -- so a contract now holds the rule mechanically: a surface
with exclusiveZone 0 may not name Theme.barHeight in a margin. It also
checks the premise it rests on, and fails if the bar ever stops
reserving its own height rather than quietly checking the wrong thing.

Measured after: date menu and clipboard at 12px, control center at 2px,
each matching what its code asks for.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:31:53 -04:00
Gabriel Brown dbd1472e6b Read busctl as JSON so device names keep their characters
A phone named "Gib's iPhone" with a typographic apostrophe was shown as
"Gib\342\200\231s iPhone".

busctl's default text output escapes every non-ASCII byte in octal, and
escapes it into the output rather than into a quoted string a
shell-style parser can undo, so shlex handed back the escape sequences
as literal characters and they went straight to the page. Apostrophes
were only the visible case: accents, emoji, quotes and backslashes were
all affected, and a name containing a quote could have split a field.

Property and method reads now use --json=short, which returns real
UTF-8, and the parsers read a document rather than splitting words.
That removes the class rather than unescaping octal by hand.

The fixtures were the reason this stayed invisible: every test fed the
text form and passed against output the helper is no longer asking for.
They now carry what busctl actually emits in the mode used, plus a case
for a non-ASCII name and one asserting the old text form is refused
rather than parsed wrongly.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:28:36 -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 8e93f08977 Show what the keyring holds, without showing what it holds
Managing a stored credential meant installing Seahorse. The keyring
rows on Privacy could say whether it was locked and nothing about what
was in it.

Four rules, each pinned by a contract, because each is a way this could
leak the thing it exists to protect:

Listing never reads values. Enumerating reports labels and attributes;
it does not ask the keyring to hand over what it is protecting.

A secret never reaches a command line. /proc makes argv readable by
every process on this machine, so a password passed as an argument is
published to all of them. The helper reads the value in process and
writes it to wl-copy on stdin.

A secret never reaches an error message, a log, or a QML property. An
exception raised while holding a password does not get to choose what
text is printed, so the clipboard tool's stderr is discarded rather
than echoed.

Forgetting one is irreversible, so the first press asks and the second
does it, and the confirming button is the only one wearing danger.

The list is collapsed until asked for: opening Privacy should not
enumerate someone's passwords as a side effect. A copied value clears
itself about a minute later, but only if the clipboard still holds it --
the guard compares a SHA-256, so the waiting process never has the
password.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:14:32 -04:00
Gabriel Brown 99433c0e8e Add a Storage page
Nothing showed what was using the drive, and removable media was handled
by a tray helper with no surface in Settings at all.

One scroll rather than tabs: space above, the device below. Every other
settings page is a scrolling card stack, and a tab would not be
deep-linkable from the launcher command or from search.

Three things the page has to get right, each now pinned by a contract,
because each is a way it could quietly lie. / and /home are one btrfs
filesystem sharing one pool of free space, and a page that copies df
shows double the free space that exists. zram is a block device and is
not storage; counting it as a drive overstates this machine by 8 GB.
Unmount and eject refuse anything not on a removable drive, because the
UI is what asks and a UI can be wrong.

The cheap read -- layout, usage, health -- runs when the page opens, at
around 90ms. Measuring what is filling the drive means walking every
file, so it happens on request and says so rather than showing an empty
list that reads as "nothing here".

Partitioning and formatting are deliberately absent. A settings pane is
the wrong place to put erasing a disk two clicks deep; the page opens
GNOME Disks for that.

Adding the page found a fourth hard-coded page list in ShellState. A
page missing from it does not error -- openSettings() falls back to
"home", so the launcher opens the wrong page and logs nothing. A
registry contract now holds the three lists together.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 10:52:10 -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 91306ce810 Give libadwaita applications the desktop's accent
Files, Papers, Loupe and every other libadwaita application read their
accent from the Settings portal, so they rendered in GNOME blue no
matter which accent this desktop was set to -- correct on our own
surfaces, wrong on half the screen.

xdg-desktop-portal-gtk cannot serve org.freedesktop.appearance
accent-color at all; the string does not appear in the 1.15.3 binary.
The gnome backend serves it, so Settings now routes to gnome with gtk
still listed behind it -- the frontend merges Settings backends in
order, so color-scheme keeps resolving if the gnome backend is ever
unavailable.

GNOME's accent-color is a fixed enum of nine names rather than a color,
so each of our eight accents carries its nearest member. Nearest by hue
rather than by name: rose maps to red, because it is the red role in
this palette.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:31:50 -04:00
Gabriel Brown 1b94af1163 Plan the desktop integration work
Six areas an OS is expected to own that this desktop still delegates:
preview, GTK accent cohesion, storage, launcher search, secrets, and
backups. Ordered so each is useful the day it lands.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:19:40 -04:00
Gabriel Brown b4ce148caf Give each default-application role a whole family of types
Every role carried a single representative type, so setting "Images"
changed image/png and left image/jpeg wherever it landed. That is how
this desktop ended up opening PDFs in GIMP, PNGs in a pixel-art editor
and MP3s in a video transcoder: nobody chose any of it, applications
registered themselves for everything they could read, and the roles
governed one type each.

Roles now own families and write every type when set, the settings page
exposes the documents, text and archives roles it never offered, and a
new seed command curates a fresh machine during setup while always
keeping a choice the user has already made.

The shipped editor entry launches kitty explicitly. The stock
nvim.desktop sets Terminal=true, which defers to whatever the system
considers default rather than the terminal this desktop themes.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 09:17:33 -04:00
Gabriel Brown 2fcaada7e8 Snapshot automatically before restoring defaults
Restoring defaults clears every preference and the Home accessory store,
and nothing in the app can undo it. Snapshots existed but were entirely
manual, so the one irreversible action Panama offers was also the one
with no safety net.

It now snapshots first. Not fatal if that fails: someone who asked to
reset gets their reset, and a snapshot that could not be written is
reported rather than allowed to block what they asked for.

The wiring is inverted deliberately. SettingsBackup already references
SystemSettings, so referencing it back would make two singletons depend
on each other, which is an initialisation-order problem waiting to
happen. Instead SystemSettings exposes a seam defaulting to a no-op and
SettingsBackup installs itself into it at startup -- the same shape as
the seams the reset path already uses for test isolation.

The contract asserts the snapshot is FIRST in the call sequence, not
merely present. A snapshot taken after the stores were cleared would
faithfully record the wiped state as the user's own, which is worse than
no snapshot: it looks like a safety net and is a copy of the damage.
Verified against both mutations -- removing the snapshot, and moving it
after the wipe.

One trap, hit for the third time today: QML allows only one
Component.onCompleted per object, and SettingsBackup already had one.
Adding a second does not fail locally -- it poisons the entire services
module, so every singleton reports "Type X unavailable" and the real
error is the last line of a forty-line cascade.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 08:37:43 -04:00
Gabriel Brown 588dec4adc Generate the settings reference from the schema
Every other form of documentation here has drifted at least once today:
search routing that pointed at a page not containing the setting, a
contract that pinned the bug the same commit fixed, and a comment in
shell.qml that failed to stop me making the exact mistake it described.
Prose describing 127 settings would drift the day after it was written.

So docs/settings.md is generated, and a contract fails the moment the
committed copy stops matching the schema. The document cannot be wrong
for longer than it takes to run the suite.

It reads the schema by parsing rather than importing, since there is no
QML interpreter here and requiring a compositor to build documentation
would be worse. That parser is the risk, so it FAILS LOUDLY: if it stops
recognising the file it exits non-zero with the reason and writes
nothing, because a partial reference is worse than a stale one -- stale
is caught by --check, partial reads as complete. Verified: with the
entry pattern broken it reports "only 0 entries parsed" and leaves the
committed file untouched.

The contract also proves --check actually compares content, by appending
a line and confirming it fails, rather than trusting a command that
returns success to mean anything.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 08:37:43 -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 8156fc47ca Fix a false Nextcloud health warning
Two compounding bugs: the check looked for an autostart entry named
"nextcloud.desktop", but current nextcloud-client packages ship it
Title-cased as "Nextcloud.desktop" -- a case-sensitive filesystem
never matched, so it always reported "autostart is not configured"
even with autostart genuinely on. Made the lookup case-insensitive so
a future package rename doesn't reintroduce this.

Second, even past that, it ran systemctl against "nextcloud.service" --
a unit that doesn't exist in either scope, because the client is a
plain autostarted process with no systemd unit behind it at all (same
reasoning as the RustDesk autostart.lua comment, different shape: no
service to query rather than the wrong scope). Added a process_check
helper alongside the existing service_check and switched Nextcloud to
it; confirmed live it now reports "Process is running."

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-19 07:53:04 -04:00
Gabriel Brown 9bc771bcca Fix a false RustDesk health warning
The check queried `systemctl --user is-active rustdesk.service`, but
RustDesk ships an enabled *system* service (root-owned, spawns the
session --server/--tray on its own -- see autostart.lua's comment on
why Panama doesn't start it a second time). No user-scope unit by
that name exists, so the check always reported inactive regardless of
whether RustDesk was actually running. Dropped --user; confirmed live
that a plain `systemctl is-active` correctly reads the system unit
without needing root, and the doctor now reports "Service is active."

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-19 07:41:27 -04:00
Gabriel Brown fe74a82a46 Route the Background portal to xdg-desktop-portal-gnome
Nothing served org.freedesktop.impl.portal.Background under Hyprland:
gnome.portal implements it but is UseIn=gnome, same situation the
Secret mapping below already solves for gnome-keyring. Every Flatpak
app's own "run/start on login" toggle goes through this portal, so it
silently failed for all of them -- Bitwarden logged the failure on
every launch.

Verified live rather than assumed: xdg-desktop-portal-gnome starts as
a standalone backend process with no dependency on GNOME Shell or
gnome-session, and after adding the mapping and restarting the portal,
Bitwarden's own autostart request succeeded end to end
(response=Background { background: true, autostart: true }) where it
previously errored with "portal frontend ... was not found".

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 22:32:53 -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 2a716dac9e Fix correctness bugs across the helper scripts
panama-osd read the wrong brightnessctl field, showing the hardware
max instead of a percentage on any backlight device. panama-doctor
called three sibling scripts by bare name with nothing on PATH,
making three health checks permanently and falsely report broken; its
repair actions also reused the short probe timeout, so a slow-but-
successful restart was reported as failed. panama-wifi-qr left the
cleartext passphrase temp file behind on its failure path (the RETURN
trap doesn't fire on exit), and its nmcli parsing broke on connection
names containing a colon or backslash -- verified against a real
NetworkManager profile.

panama-power-profile's set command always returned success regardless
of whether the write actually took. panama-keyring's daemon-origin
check picked whichever gnome-keyring-daemon process happened to
enumerate first in /proc, defeating the exact dual-daemon scenario it
exists to detect; it now resolves the PID that actually owns the
Secret Service D-Bus name. gnf aborted before running a firmware
update whenever the metadata was already current (a non-error exit
under set -e), and its flatpak update lacked the -y its own docs
promise.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:28 -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 8b59b78d9f Settle process-signal races across the services layer
A Process's exited and streamFinished signals aren't guaranteed to
fire in order, and several services decided an outcome on whichever
fired first: KdeConnect could report a successful file transfer as
failed if exited landed before the real stdout payload; Clipboard
could present a failed history query as an empty-but-healthy one;
Brightness could strand the last queued write of a drag; SoundFeedback
and SystemLocale could drop or misapply a rapid second toggle/click
because re-arming an already-running Process is a no-op. All five now
wait for both signals and let the authoritative one decide, matching
the pattern HomeAssistantConfig.qml already used correctly.

Health's "copy report" never enabled stdin, so it copied nothing
while claiming success. Capture announced every recording as saved
regardless of the recorder's actual exit code. Connectivity never
restarted Bluetooth discovery when the adapter was enabled from an
already-open page. CalendarAgenda left the UI in "loading" forever if
its helper died at startup, and the helper itself could crash
unguarded instead of reporting unavailable. Geocoding silently
dropped a query typed while the previous one was still in flight.
Notifs leaked tracked-but-undisplayed notifications under Do Not
Disturb, and dismissAll() skipped them.

Claude-Session: https://claude.ai/code/session_01E6TJUAh41HaP25MVHWkhRZ
2026-08-18 21:23:07 -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 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 c37ca3baee Make the accent colour choosable
Phase 4, first slice. Theme.qml hardcoded the Prism pair, so the one
thing that carries every state meaning in the desktop -- focused, active,
on -- was the one thing nobody could change. 74 files read Theme.accent,
so making it a setting moves all of them at once.

Named accents rather than a colour picker, which is the design decision
worth defending. One hex cannot serve both schemes: a colour legible on
the Moon background is usually illegible on the Day one, and a picker
that lets someone build an unreadable desktop is not a feature. So each
name carries a curated pair per scheme, and every one of the sixteen
resulting colours measures at least 3:1 against the ground it sits on --
checked, not assumed. It is also GNOME's model, which is the parity
being chased.

The focused window border comes with it, and only because the ownership
rule made that safe. ColorScheme owns the inactive border as a
scheme-relative contrast role; the focused Prism border is the accent
role owned by the theme. Writing it from the accent would have been
reckless before that boundary existed, since a scheme change would have
erased the user's choice. Both borders are now pushed together, because
each accent carries separate light and dark pairs, so switching schemes
must restate the focused border too.

The gradient is written as a Lua table, not a string. The string form
carries only one stop, and passing two as a string is accepted and
silently keeps the previous value.

Swatches are drawn as the gradient they produce rather than as flat
dots, because the gradient is what is being chosen. Each carries its
name permanently rather than in a tooltip: telling swatches apart by
colour is precisely what someone with a colour vision deficiency cannot
do, which is also why the palette is named in the first place.

Verified end to end by switching to rose and watching the compositor
report eeff757f/eec099ff, then reverting.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-18 17:02:18 -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
1314 changed files with 148693 additions and 9089 deletions
@@ -0,0 +1,236 @@
# Panama server role: headless installs and versioned containers
2026-08-25. Designed with Gib across two interview rounds; every decision below
was made explicitly, none inferred.
## Goal
Panama becomes the one repo for every Fedora machine Gib runs. A desktop gets
what it gets today. A server — a Hetzner VPS, a future home server — gets the
same shell environment (bash profile, prompt, tmux-on-ssh, agents) minus
everything that needs a screen, plus rootless podman, systemd user units, and a
versioned catalog of compose services that `panama server enable` brings up.
The repo stays public; secrets never enter it.
## Decisions of record
| Decision | Choice |
|---|---|
| Repo | One public monorepo. No companion repo. `.gitignore` + a secrets contract keep it safe |
| Role | Asked first in the interview (`desktop` / `server`), persisted to `~/.local/state/panama/role`; `--server` on `boot`/`install` presets it |
| Layout | Top-level `server/`, services under `server/containers/<Name>/` (TitleCase, matching junior) |
| Tracked per service | `compose.yml`, `podman-<name>.service`, `.env.example`. Nothing else |
| `~/Server` | Real directories. Only `compose.yml` is symlinked into them; units symlink into `~/.config/systemd/user/`. Live `.env` and data stay outside the repo tree entirely |
| Data dirs | Standardized to `./data/`, applied per service at cutover (a stop/rename/start), never at import |
| Enablement | `panama server` subcommand family, not the interview |
| Units | Checked in per service (they legitimately differ), installed as symlinks so edits flow with `panama update` |
| Updates | `panama update` relinks and `daemon-reload`s but never restarts a running service; it names services whose definitions changed |
| Nightly image updates | junior's `update-containers.sh` + `podman-update.timer` adopted into the repo (it replaced watchtower for cause) |
| Codex | Installed everywhere, same pattern as Claude Code |
| Ports | 80/443/81 only. Anything else is opened by hand and documented in the service's folder |
| VPS bootstrap | `boot` run as root walks user-creation/keys/sshd-hardening, each step skipped when already true, then re-runs as the user |
| Out of scope | server.gib (docker; translate later), backup and verify-rootless (own design later), cutover of junior's live services (later, per service) |
## 1. Machine role
- New interview question, asked first: "What is this machine?" — `desktop` or
`server` via `gum choose`. `./install --server` (and `boot --server`) records
the answer without asking.
- Persisted to `~/.local/state/panama/role`, plain text, one word. This is the
first durable answer Panama keeps, and deliberately so: the transient-answers
principle exists to keep *personal* data out of durable paths, and a machine
role is a fact about the machine. `install`, `panama update`, `panama doctor`
and future migrations read it. A machine with no role file is a desktop —
every machine that predates this work is one.
- On role=server the interview skips: hardware probes (NVIDIA, Secure Boot,
firmware — and their bootstrap tools; only `gum` is bootstrapped), the
debloat question, and the extras checklist. It keeps: hostname, git
identity, gh sign-in, SSH key, user-content.
## 2. Stages by role
Role=desktop is exactly today's behavior. Role=server runs:
| Stage | Server behavior |
|---|---|
| `install-packages` | Core + server lists (§3). No flatpaks, no COPRs, no desktop repos. Claude Code and Codex install for both roles |
| `link-dotfiles` | Universal dot dirs only: `btop`, `nvim`, `ohmyposh`, `tmux`, `vim`, `panama`. Desktop-only (everything else: `hypr`, `quickshell`, `kitty`, `ghostty`, `espanso`, `gtk-*`, `uwsm`, `vicinae`, `wofi`, `xdg-desktop-portal`) is skipped via an explicit classification list in the stage — a new dot dir must be classified or the contract fails. Bash profile links as today; Firefox/wallpaper/cursor seeding skipped |
| `link-user` | Unchanged (SSH aliases and agent rules are exactly what a server wants) |
| `setup-identity` | Unchanged |
| `setup-server` (new) | §4 |
| `link-server` (new) | Creates `~/Server`, then delegates to `panama-server relink` so link repair has one owner |
| Skipped | `link-skills` (all three shipped skills are desktop-specific today; revisit when a server skill exists), `change-settings`, `link-vicinae-scripts`, `install-hardware` |
Migrations run on servers too — they are self-guarding no-ops where they do
not apply. `panama doctor` learns the role: on a server it reports service
health via `panama-server status` instead of probing desktop units.
## 3. Package restructure
`setup/packages/` gains `core-packages` (both roles) and `server-packages`;
`initial-packages` keeps what is desktop-only. Nothing is added or dropped for
desktops — the union of core + desktop lists equals today's set.
- **core-packages**: awk, bat, btop, curl, dnf5-plugins, eza, fontconfig, fzf,
gh, git-all, gum, jq, neovim, oh-my-posh, openssh, openssl, podman,
podman-compose, python3-dnf, ripgrep, tmux, unzip, wget, zoxide.
(`podman` moves here from development-packages; `podman-compose` is what
provides `podman compose` on junior — verified, 1.6.0.)
- **server-packages**: nvm (Node via nvm, as on desktop — agents and MCP
servers want it, and Codex installs through npm).
- **initial-packages** (desktop-only remainder): cargo, rustup, flatpak, fwupd,
kitty, ksshaskpass, libselinux-utils, pciutils, python3-neovim,
wireguard-tools, vim-X11 — plus everything else currently there that the
core list did not claim.
- Codex CLI: installed in `install-packages` beside the Claude Code step —
skip when `codex` is on PATH, `npm install -g @openai/codex` (after nvm/node
is available), soft-fail like Claude Code does.
- The package-hash mechanic in `install` is untouched; new files under
`-maxdepth 1` join the hash automatically.
## 4. `setup-server` stage
Idempotent, like every stage; each item checks before acting:
1. `loginctl enable-linger $USER`.
2. `/etc/sysctl.d/99-rootless-ports.conf``net.ipv4.ip_unprivileged_port_start=80`,
applied with `sysctl --system` (junior already has exactly this file).
3. firewalld: open 80, 443, 81 (NPM admin portal), permanent + runtime.
Nothing else — reverse-proxy philosophy, ports open only when unavoidable.
4. `podman network create nginx-bridge` if absent (every compose expects it,
`external: true`).
5. Nightly image updates: `update-containers.sh` is adopted into the repo as
`server/scripts/update-containers` (no `.sh`, per repo rule — junior's copy
is imported and its `ExecStart`/log paths updated), with
`server/systemd/podman-update.service` and `.timer` symlinked into
`~/.config/systemd/user/` and the timer enabled. Logs go to
`~/Server/logs/` as today.
## 5. The `panama server` command
`bin/panama-server`, surfaced as `panama server <verb>` (README's subcommand
table and its contract update with it):
- `list` — every service in `server/containers/`, with enabled/active state.
- `enable <Name>` — create `~/Server/<Name>/`; symlink `compose.yml`; seed
`~/Server/<Name>/.env` from `.env.example` when the example exists and the
live file does not; if the seeded `.env` still contains placeholder values,
stop there and say what to fill in — do **not** start; otherwise symlink the
unit, `daemon-reload`, `enable --now`.
- `disable <Name>``disable --now`, remove the unit symlink,
`daemon-reload`. `~/Server/<Name>` and its data are never touched.
- `status [<Name>]` — unit state plus `podman compose ps` per service.
- `relink` — for every service whose unit symlink exists: refresh both
symlinks, `daemon-reload`, and report services whose definitions changed
since the last run (so the operator restarts deliberately). Called by the
`link-server` stage and by `panama update` on role=server.
Live `.env` files live in `~/Server/<Name>/`, not in the repo tree — the repo
carries only `.env.example`. `.gitignore` still covers `.env` everywhere under
`server/` as belt and braces.
## 6. VPS bootstrap (`boot` as root)
`boot` gains a root path. When `EUID == 0`, before anything else, walk these
steps, each checked and skipped when already true:
1. Ask for a username (default `gib`). Create it with `wheel` if absent; if it
exists, use it.
2. Ensure the user has a password (needed for sudo) — `passwd` interactively
if none is set.
3. Copy root's `authorized_keys` only after OpenSSH parses every key line. The
destination directory and file are created as the target UID at `0700/0600`
and revalidated before hardening is available.
4. Offer sshd hardening (yes/no, default yes): atomically install
`/etc/ssh/sshd_config.d/00-panama.conf` with `PermitRootLogin no`,
`PasswordAuthentication no`, and `KbdInteractiveAuthentication no`. Run
`sshd -t` plus effective root/target `sshd -T -C` checks before reloading the
detected unit. Restore a prior regular file with metadata on failure. A
missing unit or unsupported existing drop-in leaves SSH unchanged and
bootstrap continues. The later binding transaction design owns the details.
5. Move/clone the checkout under the user's home, chown it, and re-exec
`install --server` as that user.
Run as a normal user, `boot` behaves exactly as today (plus passing `--server`
through).
## 7. Importing junior's services (the first population)
A one-time operation, performed against junior over SSH, that **never stops,
starts, or restarts anything**. For each dedicated compose dir:
1. Copy `compose.yml` into `server/containers/<Name>/`.
2. Take the **installed** unit (`~/.config/systemd/user/podman-<name>.service`)
as truth; diff against the copy in the service dir and flag any divergence.
3. Move inline secrets out: secret-bearing values in the repo copy of
`compose.yml` become `${VAR}` interpolations; extracted values are written
to `~/Server/<Name>/.env` **on junior** (inert until cutover — the live
compose still carries them inline); `.env.example` with placeholder values
is committed. Services already using `.env`/`env_file` get an
`.env.example` generated from the live `.env` with values blanked.
4. Verify without touching the stack: `podman compose config` rendered from
the repo copy (with the live `.env`) must match the render of the live
compose. Volume paths are kept **verbatim** at import (`volumes/`,
`volume/`, `pg_data/` and all) so this diff can be exact; the `data/`
standardization happens per service at cutover.
5. Commit. The secrets contract (§8) gates every one of these commits.
**Import list (23):** Adminer, Agentchat, Authentik, Beszel, CalCom,
ConvexMonorepo, Documenso, Gitea, Infisical, ISponsorBlockTV, LashAddict, N8n,
Nginx_Proxy_Manager, Penpot, Portainer, PostgreSQL, Rustdesk, Spoon, StPeteIT,
Tools, Uptime, UseSend, Vaultwarden. CalCom, Documenso, Penpot and N8n are
currently parked (dir present, unit not active) — imported anyway, noted as
parked in the catalog.
**Excluded:** Watchtower (retired — replaced by the update timer after it took
gitea down; its dir stays on junior until cleanup), Completeuphoria,
sierraandtyler, Bang (source checkouts deployed by CI from their own repos —
Panama owning a second copy of their compose would drift), `ci/`, `logs/`,
and junior's loose scripts other than `update-containers.sh` (§4).
**Cutover** (per service, later, not this work): stop → rename the volume dir
to `data/` and update the repo compose to match → replace live compose with
the symlink, installed unit with the symlink → `daemon-reload` → start →
verify. Ten minutes a service, done when Gib chooses, favoring quiet hours.
## 8. Contracts and docs
New `tests/server/`, same discipline as the rest:
- `compose-secrets-contract` — fails when any tracked file under `server/`
contains an inline secret-looking value (the `user-content-contract`
pattern), when `.gitignore` stops covering `.env` under `server/`, or when
`git ls-files` shows a tracked `.env`.
- `containers-shape-contract` — every `server/containers/<Name>/` carries
`compose.yml` + `podman-<name>.service`; every unit's `WorkingDirectory` is
`%h/Server/<Name>`; every `${VAR}` used in a compose file appears in that
service's `.env.example`.
- `panama-server-contract` — throwaway `HOME`, stubbed `systemctl`/`podman` on
PATH: `enable` creates the right symlinks and seeds `.env`; placeholder
values block start; `disable` leaves data alone; `relink` reports changed
definitions.
- `role-contract` — the interview asks role first, `--server` presets it, the
role file is written, stages filter correctly per role (via the stage
scripts' static/stubbed seams).
- `dotfile-classification-contract` — every dir in `config/dot/` appears in
exactly one of link-dotfiles' universal/desktop lists.
- README: layout section gains `server/`, the subcommand table gains
`panama server`, the pinned contract count moves — `readme-contract` and the
interview/link-dotfiles contracts that assert today's behavior update in the
same change.
## Phasing
1. **Role plumbing** — interview question, role file, `--server`, package
split, stage filters, `setup-server`, boot's root path.
2. **Server skeleton**`server/` tree, `panama-server`, `link-server`,
update-timer adoption, contracts, README/docs.
3. **Import** — junior's 23 services into `server/containers/` per §7.
4. **Later, separately** — per-service cutover on junior; backup and
verify-rootless design; server.gib docker→podman translation; a real
end-to-end test on a fresh VPS.
Testing note: contracts for phases 12 run with stubs and throwaway HOMEs, but
per standing practice all suite runs batch at the end of the session with
Gib's go-ahead. Phase 3 touches junior only with read commands plus the inert
`.env` writes described in §7.
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(coredumpctl list:*)",
"Bash(coredumpctl info:*)",
"Bash(journalctl:*)",
"Bash(rpm -q:*)",
"Bash(panama doctor:*)"
]
}
}
+133
View File
@@ -0,0 +1,133 @@
---
name: panama
description: Use when working on the Panama repository itself — editing anything under config/, setup/, bin/, tests/, or the Quickshell shell and Hyprland Lua it ships. Covers the live-desktop hot-reload rules, the contract-test discipline, the fixture seams, and where the real documentation lives.
---
# panama
You are editing a running desktop, not a codebase that gets deployed later. Every dotfile in this
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
drives every rule below.
Read before large work, in this order:
- `README.md` — layout, the `panama` command, how installing and updating work
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
thing to know first" and "Never use `hyprctl keyword`" before touching it
- `config/dot/quickshell/modules/settings/README.md` — adding a setting, setting ownership,
"Things that will bite you", and where state lives; the most load-bearing document here
- `docs/settings.md` — generated reference for every settings key (never edit by hand)
## The live-desktop covenant
Editing `config/dot/quickshell/**` hot-reloads the live shell on every save.
1. Every save must leave valid QML. An intermediate broken save is a broken desktop, not a broken
build. Convert a component and its body in ONE edit, never two.
2. After each batch of saves, check the journal for errors AND "Unable to assign" warnings:
`journalctl --user -u panama-quickshell.service --since '-2 minutes' --no-pager`
3. Every `.qml` file in `config/dot/quickshell/modules/*` must be registered in that directory's
`qmldir` in the same save batch. An unregistered component fails the whole configuration and
takes down the bar, dock, and settings together. `tests/quickshell/qmldir-registration-contract`
is pure file inspection — run it before the change lands.
4. Never `qs kill` from a copied configuration: Quickshell derives the shell ID from config
content, so a content-identical harness can share the live shell's ID and kill the desktop.
Harnesses use a distinct entry file, `qs -p`, the PID from `qs list --all`, and `kill` that PID.
5. Never `hyprctl keyword` — Lua-configured Hyprland refuses it, prints to stdout, and exits 0.
`hyprctl eval` also exits 0 on errors. The only proof a write landed is `hyprctl getoption`
reading it back. Batch `hyprctl reload` to one per verified change-set, and say when you do it.
6. IPC: annotate every parameter and return type or Quickshell silently skips registration; never
duplicate an `IpcHandler` target — one silently shadows the other.
7. In `config/dot/quickshell/config/PreferenceSchema.qml`, `readAs` describes the compositor's
ANSWER shape, not the setting's; a wrong one makes every successful write look rejected.
8. No continuously repainting animations (pulse, shimmer, spinners) — they peg the GPU on
high-refresh displays.
9. `hyprlock.conf`, `hypridle.conf`, `hyprpaper.conf`, `hyprtoolkit.conf` never moved to Lua. Do
not "fix" them. Do not install a notification daemon — the shell is one.
10. Scripts carry no `.sh` extension (shebang + `chmod +x`), with one deliberate exception:
`migrations/*.sh`, which `bin/panama-migrate` globs. No secrets under `user/` — it is
world-readable and `tests/setup/user-content-contract` greps for key material.
## The contract discipline
Every executable contract under `tests/` is classified in `tests/contracts.manifest`. The rules:
- `panama contracts <file>` names the contracts that mention a file; run those after touching it.
- During a desktop session, run `panama test --safe [pattern]`. `--safe` selects only contracts
classified as `hermetic`.
- A plain full run prompts in a terminal before any selected non-hermetic contract starts. In
automation, grant every required capability with repeatable flags, for example
`panama test --allow live-compositor --allow live-desktop keybinds`.
- Each contract has a 180-second outer timeout by default. Override it with a positive
`PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr.
Successful stdout stays quiet. Successful stderr is surfaced as a warning.
- Contracts run directly too: `tests/setup/interview-contract`.
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
- `README.md` pins the contract count and the `panama` subcommand list
(`tests/setup/readme-contract`); adding a contract or subcommand means updating it.
## Seams for testing
Helpers are built hermetic: validated inputs, JSON out, secrets on stdin only (never argv).
Contracts reach them through env seams — `PANAMA_NETWORK_HELPER`, `PANAMA_FINGERPRINT_FIXTURE`,
`PANAMA_EXTRAS_DIR`, and the `PANAMA_*_STATIC_ONLY` family that cuts a contract to its
file-inspection half. Prefer exercising a helper through its contract's stubbed PATH over running
it against the real system.
## Safety idioms (contract-enforced)
- Destructive actions are two-stage: `ConfirmAction` with a unique `actionId`, arbitrated through
`ShellState.armedConfirm` — one armed confirm app-wide. Danger tone marks only the confirming
press.
- Failures render through `ErrorRow`; honest empty states through `NotMeasuredRow` with a
`because:`; paragraph-length notes through `SettingsNote`.
- Stored user actions (custom shortcuts, gestures, window rules) are DATA — an enum kind and a
validated target resolved through whitelist tables in `config/dot/hypr/actions.lua`. Nothing in
`~/.config/panama/settings.json` may ever be an executable command; the contracts
(`tests/quickshell/settings-idiom-contract`, `tests/quickshell/keybind-rebind-contract`) pin all
of this.
## Root work
Never bare `sudo`. Load the `panama-sudo` skill first. Migrations already follow the rule.
## Initial bootstrap trust
Use the complete command in `README.md` for a new machine. It downloads `boot`
from the documented commit URL into a private temporary directory, enforces the
documented curl time and byte limits, verifies the committed blob with
`sha256sum -c`, and passes both pins to the verified script. Desktop and server
bootstrap use the same commit and digest. Never substitute a branch URL, pipe a
response into Bash, or invent a newer pin from an uncommitted file.
`boot` validates the full lowercase commit and SHA-256 before Git or install. A
fresh destination fetches only that revision and creates a tracked local `main`
after HEAD matches. An existing checkout must be clean and an ancestor of the
requested revision; it advances with fast-forward only. Dirty, divergent,
fetch-failed, or mismatched checkouts stop without reset or install. Once Panama
exists, use `panama update` for normal updates.
`boot --server` is the one exception where a new Fedora VPS may begin as root.
Before it offers SSH hardening, it copies a safe root key when possible or
verifies the target key. The target user's `.ssh` must be owned by that user at
`0700`, and `authorized_keys` must be owned by that user at `0600`. Without a
verified target key, SSH hardening is unavailable and the bootstrap continues.
Every non-comment key line must parse with `ssh-keygen`. Root-key destination
creation and writing run as the target UID, followed by the same owner, mode,
and key checks. Do not replace that with root writes or assume the user's
primary group matches the username.
Accepted hardening uses atomic same-directory `00-panama.conf` with exactly
`PermitRootLogin no`, `PasswordAuthentication no`, and
`KbdInteractiveAuthentication no`. A pre-existing symlink or non-regular
object makes hardening unavailable, as does a missing SSH unit. Panama runs
`sshd -t`, then checks effective root and target-user policy with `sshd -T -C`
before reloading the detected unit. Validation or reload failure restores a
prior regular file with its metadata before it retries validation and reload.
Failed recovery stops the handoff with instructions that distinguish a prior
file from no prior file. The fixture contracts also cover declined hardening
and interrupted preparation. `panama test --safe` never reloads a live daemon,
so it is not live-host proof.
+27 -3
View File
@@ -1,5 +1,15 @@
# Ignore bash environment variables. # Ignore bash environment variables, and the lock its writer takes.
/config/bash/env /config/bash/env
/config/bash/.env.lock
# Server secrets and state never enter the repo: live .env files belong in
# ~/Server/<Name>/ (only compose.yml is symlinked back here), and data/ is
# the bind-mount convention. Ignored anyway as a seatbelt -- the repo is
# public, and tests/server/compose-secrets-contract is the check that this
# line still exists.
/server/containers/**/.env
/server/containers/**/data/
# Personal espanso triggers (name, email), seeded per-machine by setup-identity.
/config/dot/espanso/match/identity.yml
# Ignore backups of old config files # Ignore backups of old config files
/config/old /config/old
# Ignore Wireguard config of course! # Ignore Wireguard config of course!
@@ -14,11 +24,25 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
# Generated from the colour scheme; machine state, not configuration. # Generated from the color scheme; machine state, not configuration.
/config/dot/kitty/current-theme.conf /config/dot/kitty/current-theme.conf
# Generated from the colour scheme; machine state, not configuration. # Generated from the color scheme; machine state, not configuration.
/config/dot/gtk-3.0/settings.ini /config/dot/gtk-3.0/settings.ini
/config/dot/gtk-3.0/bookmarks
/config/dot/gtk-4.0/settings.ini /config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf /config/dot/tmux/current-theme.conf
/config/dot/hypr/hyprlock.conf /config/dot/hypr/hyprlock.conf
# The Firefox chrome override sheet, rendered from the active theme. It sits
# beside custom.css because chrome/ is symlinked into the profile as one
# directory; custom.css imports it and stays the file a person edits.
/config/firefox/chrome/panama-theme.css
/config/firefox/chrome/panama-theme.css.tmp
# Build products of the Vicinae extension. The source and reviewed lockfile are
# in the repository; the dependency tree and bundle are machine state.
/config/local/share/vicinae/extensions/*/node_modules/
/config/local/share/vicinae/extensions/*/dist/
/config/local/share/vicinae/extensions/*/build/
/config/local/share/vicinae/extensions/*/package-lock.json
!/config/local/share/vicinae/extensions/panama-search/package-lock.json
+334 -20
View File
@@ -1,46 +1,214 @@
# Panama # Panama
Formerly Sunhat. A personal config for Fedora, with the intention of helping a Formerly Sunhat. A personal config for Fedora, with the intention of helping a
user set up their Fedora system with one command. user set up their Fedora system with one checked command.
```sh ```sh
git clone https://git.gbrown.org/gib/Panama.git ~/.local/share/Panama (
~/.local/share/Panama/install set -euo pipefail
bootstrap_commit=0d1841cf86975d52485c94c0caa5d1b67e5c54a1
bootstrap_sha=fd8dcd70d1266da109972df346e3d85d1037f6f6e1942b0157064412ab892372
bootstrap_dir="$(umask 077; mktemp -d)"
trap 'rm -rf -- "$bootstrap_dir"' EXIT
bootstrap="$bootstrap_dir/boot"
curl --fail --location --connect-timeout 10 --max-time 30 --max-filesize 262144 \
--output "$bootstrap" \
"https://git.gbrown.org/gib/Panama/raw/commit/$bootstrap_commit/boot"
printf '%s %s\n' "$bootstrap_sha" "$bootstrap" >"$bootstrap_dir/boot.sha256"
sha256sum -c "$bootstrap_dir/boot.sha256"
PANAMA_BOOT_REVISION="$bootstrap_commit" PANAMA_BOOT_SHA256="$bootstrap_sha" \
bash "$bootstrap"
)
``` ```
`install` runs everything in `setup/scripts/` in order: `boot` installs git if the machine lacks it, creates a verified checkout at
`~/.local/share/Panama` (or `$PANAMA_PATH`), and hands off to `install`. It is
deliberately small enough to read first. The command downloads one immutable
`boot` blob into a private temporary directory, checks its SHA-256, then asks
that verified script to fetch the same commit. A clean existing checkout may
fast-forward to that exact revision. A dirty or divergent checkout, a failed
fetch, or a revision mismatch stops before `install`; Panama never resets local
work or falls back to whatever checkout happens to be present.
Once a machine exists, the command that keeps it current is `panama update`
one command, and it never asks you anything:
```sh
panama update
```
It pulls, applies any repairs this machine has not had, and runs the stages
below that need no answers. `./install` remains what it is: how a machine is
built, and how you change an answer you gave.
The first question the interview asks is what the machine is: a **desktop**
gets everything below; a **server** — a VPS, a headless box — gets the same
shell environment minus everything that needs a screen, plus rootless podman
and the compose services in [`server/`](server/README.md). The answer is the
one thing Panama records durably (`~/.local/state/panama/role`), because
`panama update` asks nothing and still has to know. A fresh server skips the
question entirely. It uses the same immutable boot blob and pins as the desktop
command; only the final `--server` argument differs:
```sh
(
set -euo pipefail
bootstrap_commit=0d1841cf86975d52485c94c0caa5d1b67e5c54a1
bootstrap_sha=fd8dcd70d1266da109972df346e3d85d1037f6f6e1942b0157064412ab892372
bootstrap_dir="$(umask 077; mktemp -d)"
trap 'rm -rf -- "$bootstrap_dir"' EXIT
bootstrap="$bootstrap_dir/boot"
curl --fail --location --connect-timeout 10 --max-time 30 --max-filesize 262144 \
--output "$bootstrap" \
"https://git.gbrown.org/gib/Panama/raw/commit/$bootstrap_commit/boot"
printf '%s %s\n' "$bootstrap_sha" "$bootstrap" >"$bootstrap_dir/boot.sha256"
sha256sum -c "$bootstrap_dir/boot.sha256"
PANAMA_BOOT_REVISION="$bootstrap_commit" PANAMA_BOOT_SHA256="$bootstrap_sha" \
bash "$bootstrap" --server
)
```
That command also works from a brand-new VPS's **root** login. It creates or
reuses your sudo-enabled user, then copies a safe root key when it can or
verifies the target key before offering SSH hardening. A verified target key
means the target user owns `.ssh` with mode `0700` and `authorized_keys` with
mode `0600`. Every non-comment line in `authorized_keys` must be valid OpenSSH
key material that `ssh-keygen` can parse. Root-key destination writes run as
the target user, and Panama rechecks the resulting owner, modes, and keys. SSH
hardening is unavailable without a verified target key or installed SSH unit,
and the install continues without it. Declining hardening also leaves SSH
unchanged.
When you accept hardening, Panama uses an atomic same-directory drop-in named
`00-panama.conf`, whose early filename gives it safer precedence. A pre-existing
symlink or non-regular object makes hardening unavailable. The effective policy
is exactly `PermitRootLogin no`, `PasswordAuthentication no`, and
`KbdInteractiveAuthentication no`. Panama validates syntax with `sshd -t` and
checks `sshd -T -C` for both root and target-user contexts before it reloads
the detected SSH unit. If validation or reload fails, it restores the previous
drop-in regular file with its metadata and validates and reloads that restored
configuration. Recovery that cannot complete stops the handoff and prints the
right manual command for either a prior file or no prior file. The fixture
contracts test these branches. No real daemon reload runs under
`panama test --safe`, so that suite is not live-host proof.
After that, it hands off to a normal install as the new user.
`install` asks its questions first and then runs the stages in `setup/scripts/`
in order, without stopping again:
| Script | Does | | Script | Does |
|---|---| |---|---|
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), then the four package lists in `setup/packages/` | | `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
| `link-dotfiles` | Symlinks `config/dot/<name>``~/.config/<name>` | | `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
| `link-dotfiles` | Symlinks `config/dot/<name>``~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
| `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
| `setup-identity` | git config, `gh auth login`, an SSH key — whichever were asked for |
| `install-hardware` | NVIDIA, Secure Boot enrollment, Fedora's extras, firmware — each only if it was asked for. Last, because enrollment and firmware are consumed at the next boot |
| `setup-server` | Server role only: linger, unprivileged ports from 80, firewalld (80/443/81), the `nginx-bridge` network, and the nightly image-update timer |
| `link-server` | Server role only: creates `~/Server` and refreshes the links of whatever services this machine has enabled |
A server runs the shared stages plus its own two, and skips the rest —
`link-skills`, `change-settings`, `link-vicinae-scripts` and
`install-hardware` are desktop concerns. `link-dotfiles` links only the
universal dot dirs there (bash, nvim, tmux, btop and tmux theming, the hook
samples), and `install-packages` takes a short path: the core tools, nvm,
Bun, Claude Code and Codex — no third-party repos, no desktop, no flatpaks.
Then `panama migrate` applies any repairs this machine has not had yet. That is
the half of an upgrade installing cannot do: the stages above only ever add, so
nothing there can remove a file this repository stopped shipping or repair a
symlink that now points nowhere. A fresh machine is marked as already caught up
rather than having those repairs run against it, and a machine with work waiting
is told at the next login rather than left to find out.
The run ends with a health summary from `panama-doctor`, which reports what is
actually running rather than what was attempted. It never fails the install: on a
fresh machine it legitimately reports things as not yet configured.
### Optional applications
Every machine gets the lists in `setup/packages/`. The interview also offers the
categories in `setup/packages/extras/` as a checklist, so a work laptop need not
acquire emulators and a desktop need not skip Steam. Nothing is preselected.
A category is one file. A bare line is a dnf package and a `flatpak:` line is a
Flathub ID, because the applications in a category do not all come from one
place. Adding a category is adding a file — the menu is read from the directory,
not written down anywhere.
Existing configs are moved to `config/old/` rather than overwritten. Existing configs are moved to `config/old/` rather than overwritten.
## Desktops ## The desktop
Panama configures **two** desktops that coexist. Both sessions stay available in Hyprland, with a shell written from scratch. It began as a replacement for a
GDM, so you can switch back and forth while you settle in. GNOME session — Forge for tiling, Dash-to-Dock, Openbar, Vitals — and was built
to reproduce it closely enough that muscle memory transferred: same keybinds,
same panel contents, same dock, same Tokyo Night Moon palette.
### GNOME That is history now rather than a second option. Panama installs and configures
The original setup: Forge for tiling, Dash-to-Dock, Openbar, Vitals, one desktop, and the GNOME session it grew out of is neither installed nor
AppIndicator support. Configured through `config/dot/forge/` and gsettings. configured here. What each piece replaced is recorded in
[`config/dot/hypr/DESKTOP-PARITY.md`](config/dot/hypr/DESKTOP-PARITY.md) and in
the comments of the components themselves, because knowing what a thing was
modelled on explains why it behaves the way it does.
### Hyprland GNOME is not gone from the machine: `gnome-control-center` is a declared
A from-scratch replacement built to reproduce the GNOME setup closely enough dependency, and two rows in Panama's own Settings still open it. Adding an
that muscle memory transfers — same keybinds, same panel contents, same dock, online account goes through the provider's dialog, because the OAuth sign-in
same Tokyo Night Moon palette. runs inside a library Fedora ships without a binding anything else can call.
And Digital wellbeing — screen time and break reminders — is the one panel of
GNOME's that still does something Panama does not. Everything else it used to
hand over is a page here now, and
[`tests/quickshell/gnome-handoff-contract`](tests/quickshell/gnome-handoff-contract)
fails the build if a door back opens onto a panel Panama owns, because those
rows kept working perfectly long after they stopped being true.
Displays is one of the panels it owns, and the only one where a wrong answer
can leave you unable to see well enough to undo it. So every change there is
applied as one complete layout, read back from the compositor, and reverted
after fifteen seconds unless you keep it — resolution, scale, rotation,
position and primary display, and now color profile, bit depth, SDR
brightness and saturation, and mirroring with them. Two of those opt out of
part of that, for reasons rather than convenience. A per-display variable
refresh rate override is applied but never verified, because `hyprctl` reports
whether adaptive sync is live this instant rather than what was asked for. And
a mirrored display's position is not asserted at all: the compositor stacks it
on the display it mirrors and ignores the coordinates the rule carried, so
holding it to them would make Keep permanently unavailable. Monitor brightness
sits outside the transaction entirely — it is the panel's own backlight over
DDC, and the buttons on the bezel change it behind our back.
| Piece | What it is | | Piece | What it is |
|---|---| |---|---|
| `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README | | `config/dot/hypr/` | Compositor config. **Lua, not hyprlang** — see its README |
| `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI | | `config/dot/quickshell/` | The shell: bar, dock, Continuum overview, Settings, Screen Intelligence, focus sessions, quick settings, notifications, screenshot UI |
| `config/dot/vicinae/` | Raycast-style launcher, themed | | `config/containers/` | Container definitions systemd runs as units — currently the speech-to-text server behind dictation |
| `config/dot/vicinae/` | Raycast-style launcher, themed. Its commands live in `config/local/share/vicinae/` — script commands (settings deep links, power actions, projects, reminders, window switcher, kill process, SSH hosts, recent files, color picker), and one compiled extension that adds web search with live suggestions. File search, calculator, clipboard, and emoji are Vicinae's own |
| `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) | | `config/dot/uwsm/` | Session environment (see the uwsm caveat in the hypr README) |
| `config/dot/wofi/` | Fallback launcher, in case the shell fails to start | | `config/dot/wofi/` | Fallback launcher, in case the shell fails to start |
| `config/dot/xdg-desktop-portal/` | Portal backend routing | | `config/dot/xdg-desktop-portal/` | Portal backend routing |
New machines get a welcome card on first start, teaching the handful of keys
that matter and reachable afterwards from the launcher. `SUPER + /` shows every
shortcut the machine has, read from the live keymap so a rebind appears there
without anything being kept in sync. Rebinding is Settings' job, and so is
adding a shortcut of your own or assigning the four-finger touchpad swipes: an
entry names an application, a shell action or a window move rather than
carrying a command, so a hand-edited settings file can move a key but cannot
make one run something arbitrary. Settings carries a
[manual](config/dot/quickshell/manual/) written for the person using the
desktop rather than the person building it, opening with a chapter for people
arriving from GNOME, macOS or Windows.
Laptops work: battery, charge limits, idle timings that differ on battery, and
a closed lid that suspends when you are leaving and keeps working when you are
docked. A desktop sees none of it, because every one of those surfaces hides
when the hardware is absent.
**Start here: [`config/dot/hypr/README.md`](config/dot/hypr/README.md)** — it **Start here: [`config/dot/hypr/README.md`](config/dot/hypr/README.md)** — it
covers the Lua migration, the uwsm environment gotcha, the HDR decision, the covers the Lua migration, the uwsm environment gotcha, the HDR decision, the
full keymap, and troubleshooting. full keymap, and troubleshooting.
@@ -50,13 +218,159 @@ Log in as **"Hyprland (uwsm-managed)"**, not plain "Hyprland".
## Layout ## Layout
``` ```
bin/ Small user-facing commands on PATH bin/ Small user-facing commands on PATH; `panama` is the entry point
migrations/ One repair per file, for machines installed before a change
config/ config/
bash/ .bashrc, aliases, env (env is gitignored) bash/ .bashrc, aliases, env (env is gitignored)
copy/ Files copied verbatim over / (needs sudo) copy/ Files copied verbatim over / (needs sudo)
dot/ Symlinked into ~/.config dot/ Symlinked into ~/.config; quickshell/manual/ is the manual
Settings renders
firefox/ Vendored Firefox chrome, linked into the browser profile
containers/ Quadlets, linked into ~/.config/containers/systemd
local/ Icons, the cursor theme, and the launcher's commands and
extensions, linked into ~/.local/share
old/ Backups of whatever was replaced (gitignored) old/ Backups of whatever was replaced (gitignored)
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
server/ The server role: compose services (one directory per
service), the nightly image updater, and its units. See
server/README.md
skills/ Agent skills for operating this desktop, linked into
~/.claude/skills
setup/ setup/
packages/ One package per line apps/ Applications built from source, one file each
lib/ Shared by more than one stage; the extras catalog reader
packages/ One package per line; extras/ holds the optional categories
scripts/ Run in order by ./install scripts/ Run in order by ./install
tests/ Contracts. See below
docs/ Settings reference, and the design specs behind the work
``` ```
## Tests
188 of them, under `tests/`. `tests/contracts.manifest` classifies every
contract by the capabilities it needs. Run the hermetic set, or grant a
specific external capability when automation needs it:
```sh
panama test --safe
panama test --allow live-host updates
panama test --allow live-compositor keybinds
PANAMA_TEST_TIMEOUT_SECONDS=300 panama test --safe
tests/setup/interview-contract # or one directly; they are plain executables
```
`--safe` means hermetic contracts only. A plain full run prompts in a terminal
before it starts any non-hermetic contract. Automation cannot answer that
prompt, so it must grant every required capability with a repeatable `--allow`.
Each contract has a 180-second outer timeout by default. Set
`PANAMA_TEST_TIMEOUT_SECONDS` to a positive integer to override it. When a
contract fails, the runner prints its captured stdout and stderr. Successful
stdout stays quiet. Successful stderr is surfaced as a warning.
They are called contracts rather than unit tests because that is what they are:
each one pins a decision that was expensive to get right and is cheap to undo by
accident. Most read or measure the real thing — launching a shell to measure a
surface's geometry, standing stub commands on `PATH` to see what a stage would
have installed, running a script against a throwaway `HOME` — rather than
asserting things about source text, because the bugs worth catching here have all
been ones that source text looked fine for.
```
tests/setup/ The installer: the interview, package lists, hardware, extras
tests/quickshell/ The shell and its settings pages
tests/hypr/ The compositor config
tests/server/ The server role: the service catalog's shape, the secrets
rule that keeps a public repo safe, and panama-server
```
## Projects
A project is the set of windows you open together — which applications, which
workspace each was on, and for a terminal, which directory it was sitting in.
Arrange the desktop, then run **Save Layout as Project** from the launcher and
name it; **Open Project** lays it out again.
Workspaces are recorded as positions rather than numbers, and opening a project
claims free ones, so it never lands on top of what you are already doing. An
application that refuses to open twice — Slack, Thunderbird, the browser — is
moved into place rather than launched again. Saved layouts are listed on
Shell Workspaces in Settings, which is also where they are removed.
## The `panama` command
```sh
panama update # bring this machine up to date; asks nothing
panama sync # review, commit and push your changes to this repo
panama edit # open it in Neovim
panama doctor # what is actually running, not what was installed
panama diagnose # hand the health summary and recent errors to your agent
panama test # every contract, prompting before non-hermetic work
panama test --safe # hermetic contracts only
panama test --allow live-host updates # grant one capability to automation
panama contracts <file> # which contracts mention a file, and can they be run
panama migrate # apply repairs this machine has not had yet
panama upgrade # re-run ./install from anywhere, interview and all
panama apps # choose applications to install, by category
panama app # applications no repository carries; build one by name
panama server # the services a server runs: list, enable, disable, status
```
`panama update` and `panama sync` are separate verbs on purpose. One acts on
the machine, the other on the repository. A single command that chose between
them by checking whether the working tree happened to be dirty would do a
different job depending on state nobody can see — and, worse, would never
update a machine belonging to somebody who had left a file edited.
`panama update` stashes uncommitted work across the pull and restores it
afterwards. If restoring conflicts it resets the checkout and leaves the work
in the stash, saying so at the end of the run: every dotfile here is a symlink
into this repository, so a conflict marker is not a thing to fix at leisure. It
is live in `~/.config` the moment it is written.
Only two things still need `./install`: a machine that does not exist yet, and
an answer you want to change. Adding a package to a list you already have is
`panama update`; adding an optional category is `panama apps`.
`panama apps` is the optional-application catalog, opened after the fact. The
interview offers the same categories during `./install`, whole; this picks a
category and then the applications inside it, so a machine can acquire Slack in
March without having wanted Discord in January. Both read
`setup/lib/extras-catalog`, so the two cannot describe different catalogues.
A category is one file under `setup/packages/extras/`. A bare line is a dnf
package, a `flatpak:` line is a Flathub id, `| Name` gives the menu something
readable, and an indented line belongs to the entry above it — which is how OBS
carries its sixteen plugin extensions as one thing to tick.
Hooks are the extension point: drop a script at `~/.config/panama/hooks/theme-set`
and it runs whenever the color scheme changes, with the scheme and accent as
arguments. Same for `post-upgrade` and `post-migrate`, and a `<name>.d/`
directory beside each so several things can react without fighting over one
file. A broken hook is reported and stepped over, never fatal. Samples are
copied into place on install.
`panama migrate` applies repairs an installed machine has not had yet. Safe to
re-run: nothing is applied twice, and a machine with nothing waiting says so.
`panama-sudo` is pkexec with a stated reason: `panama-sudo --reason "why" --
command` shows the reason on Panama's password prompt, clearly labeled as an
unverified claim beside polkitd's own action text — meant for agents and
scripts, so the person typing the password learns why before they do. Without
a reason, a running shell, or `qs` it behaves exactly like pkexec.
`panama app` is deliberately not part of `./install`. Everything else Panama
installs comes from dnf or Flathub; these are built from source because no
packaged form exists, and a source build is slow, wants the network throughout,
and depends on an upstream that moves. That is the failure the interview exists
to prevent, so asking for one is something you do on purpose — and it is also
how you rebuild when a new version ships. Nothing is pinned: each build takes
the current upstream and reports a failure rather than working around it.
Adding one is adding a file to `setup/apps/`, and the file has to say why the
exception exists.
No script in this repository carries a `.sh` extension, with one deliberate
exception: each repair in `migrations/` is named `<timestamp>.sh`, because
`panama-migrate` finds them by globbing exactly that. Everywhere else a shebang
and the executable bit already select the interpreter, and the extension only
becomes something to keep in sync — which it did not stay.
+5 -2
View File
@@ -152,11 +152,14 @@ case "$cmd" in
# 2.4) Run flatpak updates (user then system) # 2.4) Run flatpak updates (user then system)
flatpak update -y flatpak update -y
sudo flatpak update sudo flatpak update -y
# 2.5) Optional firmware via fwupd # 2.5) Optional firmware via fwupd
if $firmware; then if $firmware; then
sudo fwupdmgr refresh # fwupdmgr exits non-zero when metadata is already current -- that is
# not an error, but under 'set -e' it would abort the script before
# 'fwupdmgr update' ever runs.
sudo fwupdmgr refresh || true
sudo fwupdmgr update sudo fwupdmgr update
fi fi
+992 -49
View File
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# Hand a prompt to whichever coding agent this machine has chosen.
#
# Every rung of the escalation ladder ends here: a crash toast, a failed shell
# reload, a red health check, `panama diagnose`. They gather facts; this decides
# which binary runs them and puts it in a terminal you can watch and interrupt.
#
# Two settings decide everything, and both are read at press time rather than at
# start time, so choosing an agent in Settings takes effect on the next crash
# without restarting anything:
#
# preferredAgent none | claude | codex ("none" is the default: silence)
# agentAutoApprove true -> the agent starts in its own "don't stop to ask"
# mode; false -> its normal prompting mode, untouched.
#
# "none" exits 0 without a word. It is not an error to have no agent; it is the
# shipped state, and a rung that shouted about it would be a rung that gets
# turned off.
#
# panama-agent open the agent on the repo
# panama-agent --prompt "text" open it with something to work on
#
# Environment seams, for the contract and for a second checkout:
#
# PANAMA_PATH the repository; also the agent's working directory
# PANAMA_AGENT_SETTINGS the settings file to read (default: the real one)
#
# ---------------------------------------------------------------------------
# Adapted from Omarchy's bin/omarchy-agent (https://github.com/basecamp/omarchy)
#
# Copyright (c) David Heinemeier Hansson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# ---------------------------------------------------------------------------
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
# A fixed window class rather than the per-binary default, so one Hyprland rule
# can catch every agent window regardless of which agent is chosen.
readonly WINDOW_CLASS="panama-agent"
# THE INHERITED PATH IS NOT THE USER'S PATH. Every rung except `panama diagnose`
# reaches this script from the Quickshell shell, which systemd starts with
# neither PANAMA_PATH nor ~/.local/bin -- and ~/.local/bin is where both agents
# install themselves. Trusting PATH here meant a perfectly well installed agent
# reporting itself as missing, into the stderr of a detached process nobody will
# ever read: the whole ladder failing silently, which is the exact failure it
# exists to prevent.
#
# So the binary is resolved rather than named. PATH first, because a user who
# put an agent somewhere else meant it; then the XDG user bin directory, which
# is where the installers actually put them.
resolve_agent() {
local name="$1" found
found="$(command -v "$name" 2>/dev/null)" && { printf '%s' "$found"; return 0; }
[[ -x "$HOME/.local/bin/$name" ]] && { printf '%s' "$HOME/.local/bin/$name"; return 0; }
return 1
}
# The same repair, for the agent's own sake rather than this script's: an agent
# launched from a notification click would otherwise run every shell command it
# is asked to with a PATH unlike the one the user gets in a terminal. Applied
# just before the spawn rather than here, so resolve_agent above is answering
# the question the caller actually asked -- "can this be found from where I was
# started" -- instead of one this script has already fixed for itself.
repair_path() {
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) PATH="$PATH:$HOME/.local/bin" ;;
esac
export PATH
}
usage() {
cat <<'EOF'
Usage: panama-agent [--prompt "text"]
Opens the agent named by preferredAgent in a terminal, in the Panama checkout.
With no agent chosen, exits silently: choose one on Settings > System > Agents.
EOF
}
prompt=""
while (($#)); do
case "$1" in
--prompt)
prompt="${2:?--prompt needs a value}"
shift 2
;;
-h | --help)
usage
exit 0
;;
*)
printf 'panama-agent: unexpected argument: %s\n' "$1" >&2
usage >&2
exit 1
;;
esac
done
# Same shape as panama-idle's reader: a missing file, a missing key and an
# explicit null all mean "the default", because all three describe a machine
# that has never been asked the question.
read_setting() {
local key="$1" fallback="$2"
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
jq -r --arg k "$key" --arg d "$fallback" \
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
}
agent="$(read_setting preferredAgent none)"
# The shipped state. Nothing to launch, nothing to say.
[[ -n "$agent" && "$agent" != "none" ]] || exit 0
auto_approve="$(read_setting agentAutoApprove true)"
case "$agent" in
claude | codex) ;;
*)
printf 'panama-agent: unsupported preferredAgent: %s\n' "$agent" >&2
printf 'Choose one on Settings > System > Agents.\n' >&2
exit 1
;;
esac
# Resolved to a path before argv is built, so kitty is never asked to repeat a
# PATH lookup this script has already done more carefully than kitty could.
if ! agent_bin="$(resolve_agent "$agent")"; then
printf 'panama-agent: %s is not installed.\n' "$agent" >&2
printf 'Looked on PATH and in %s.\n' "$HOME/.local/bin" >&2
printf 'Install it, or choose another agent on Settings > System > Agents.\n' >&2
exit 1
fi
# The per-agent launch table. VERIFIED against the installed binaries' --help on
# 2026-08-25 (claude 2.1.245, codex-cli 0.149.1) -- these flags are not the same
# from release to release, so re-run --help before changing them.
#
# claude --permission-mode auto "auto" is one of acceptEdits/auto/
# bypassPermissions/manual/dontAsk/plan
# codex --approve-for-me routes approvals through automatic review
# inside the workspace-write sandbox
#
# With agentAutoApprove off, no mode flag is passed at all: the agent's own
# configured default is a choice the user already made, and overriding it with
# an explicit "prompt me" would be this script having an opinion it was told not
# to have.
declare -a argv=("$agent_bin")
case "$agent" in
claude) [[ "$auto_approve" == "true" ]] && argv+=(--permission-mode auto) ;;
codex) [[ "$auto_approve" == "true" ]] && argv+=(--approve-for-me) ;;
esac
# One argv element, after the option terminator. Both CLIs take the prompt as a
# trailing positional, and `--` is what stops a prompt beginning with a dash --
# or one that happens to read like a subcommand -- from being parsed as flags.
[[ -n "$prompt" ]] && argv+=(-- "$prompt")
# The checkout, not $HOME: the skills the prompts point at, the repository the
# agent is being asked about, and .claude/settings.json's pre-approved read-only
# diagnostics all live here. An agent started anywhere else finds none of them.
cd "$PANAMA_PATH"
repair_path
# setsid so the agent outlives whatever spawned it -- a notification handler, a
# crash watcher, a terminal that is about to close.
exec setsid kitty \
--directory "$PANAMA_PATH" \
--class "$WINDOW_CLASS" \
-e "${argv[@]}"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# "Something crashed" -> an agent already reading the core dump.
#
# Reached by clicking the crash notification panama-crash-watch sends, or run by
# hand against any PID in `coredumpctl list`. It gathers the four facts
# systemd-coredump recorded and points at the skill that says what to do with
# them; the method lives in the skill so it is edited in one place and works
# whichever agent is configured.
#
# panama-agent-crash <pid> [comm] [exe] [signal]
#
# The skill is named AND given as an absolute path. A harness with a skill
# mechanism follows the name; one without still has a file to read. That is the
# whole reason this ladder works for more than one agent.
#
# ---------------------------------------------------------------------------
# Adapted from Omarchy's bin/omarchy-agent-crash
# (https://github.com/basecamp/omarchy)
#
# Copyright (c) David Heinemeier Hansson
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# ---------------------------------------------------------------------------
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
pid="${1:-}"
if [[ ! "$pid" =~ ^[0-9]+$ ]]; then
printf 'Not a PID: %s\n' "${pid:-<missing>}" >&2
printf 'Usage: panama-agent-crash <pid> [comm] [exe] [signal] (see: coredumpctl list)\n' >&2
exit 1
fi
comm="${2:-unknown}"
exe="${3:-unknown}"
signal="${4:-unknown}"
skill="$PANAMA_PATH/skills/diagnose-crash/SKILL.md"
# Looked up live so a PID typed by hand still gets a timestamp. A core that has
# already been rotated away costs only the timestamp, so this is allowed to
# fail: the other four facts are enough to start on.
when="$(coredumpctl list "$pid" --no-pager --no-legend 2>/dev/null | tail -1 | cut -d' ' -f1-4)" || true
when="${when:-unknown}"
[[ -n "${when// }" ]] || when="unknown"
prompt="$(
cat <<PROMPT
A process crashed on this Panama machine and I want to know why.
What systemd-coredump recorded:
process: $comm
PID: $pid
binary: $exe
signal: $signal
time: $when
Use the diagnose-crash skill. It covers how to investigate, what to rule out
first, and what to report. If your harness has no skill mechanism, read the
skill file directly and follow it instead:
$skill
PROMPT
)"
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# "The shell would not reload" -> an agent already holding the error.
#
# Quickshell keeps the old shell running when a reload fails, which is what
# makes this rung possible at all: the desktop that just refused the new code is
# still there to notify you about it, and still there to click. shell.qml's
# onReloadFailed sends that notification; this builds the prompt behind it.
#
# panama-agent-reload "<what Quickshell said>"
#
# The failure string on its own is usually one line naming one file. The journal
# around it is where the rest is -- the QML warnings that preceded the fatal
# one, the property that was already undefined two saves ago -- so both go in.
#
# Environment seams, for the contract:
#
# PANAMA_PATH the repository
# PANAMA_RELOAD_UNIT the unit to read (default panama-quickshell.service)
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
UNIT="${PANAMA_RELOAD_UNIT:-panama-quickshell.service}"
summary="${1:-}"
if [[ -z "${summary// }" ]]; then
printf 'Usage: panama-agent-reload "<the reload failure>"\n' >&2
exit 1
fi
# Read a generous window and filter it down, rather than asking journalctl for
# forty lines and hoping they were the relevant ones. A failed reload usually
# arrives after a burst of unrelated shell chatter.
#
# Each line is truncated because the prompt leaves as one argv element and the
# kernel caps that at 128KB; a single Quickshell backtrace can be most of it.
context=""
if command -v journalctl >/dev/null 2>&1; then
context="$(journalctl --user -u "$UNIT" -n 400 --no-pager --output=cat 2>/dev/null \
| grep -iE 'quickshell|\.qml|qml:|panama' \
| tail -40 \
| cut -c 1-300)" || true
fi
[[ -n "${context// }" ]] || context="(nothing in the journal for $UNIT)"
prompt="$(
cat <<PROMPT
The Panama shell refused to reload on this machine. The old shell is still
running, so the desktop is up, but the change that was just saved is not live.
What Quickshell reported:
$summary
The last relevant lines from $UNIT:
$context
The shell lives in config/dot/quickshell in this repository, symlinked into
~/.config/quickshell -- so the file that failed to parse is a tracked file here,
not a copy. Find what broke the reload and say what it is. Read before you
write: a bad guess saved into this tree is live in the desktop immediately.
PROMPT
)"
exec "$PANAMA_PATH/bin/panama-agent" --prompt "$prompt"
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Tell somebody when a program crashes.
#
# On GNOME, ABRT says so. Under a hand-assembled Hyprland desktop nothing does,
# and applications die silently -- which is most of how "Linux is flaky" gets
# earned. Fedora ships systemd-coredump by default, so the information is
# already there; nobody is reading it.
#
# Follows the journal for systemd-coredump's own message id and reports each
# program once per session.
#
# ONCE PER SESSION IS THE WHOLE DESIGN. This machine's portal backend crashes
# between eleven and sixty times a day -- see the portal-stability check in
# panama-doctor -- and a notification per crash would be a notification every
# few minutes for something the user can do nothing about. The first one is
# news; the fortieth is why people turn notifications off. The health page
# carries the running count for anyone who wants it.
#
# When an agent has been chosen, the notification stops being a dead end. It
# carries the diagnosis command as data in a `panama-exec` hint, which the shell
# runs on click. Command-as-data rather than a libnotify action, because an
# action would tie the click to this process still being alive to hear it, and
# this process is a `journalctl -f` that outlives nothing in particular. The
# hint survives a shell restart and never blocks the watcher.
set -uo pipefail
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
SETTINGS="${PANAMA_AGENT_SETTINGS:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json}"
# systemd-coredump's MESSAGE_ID. Matching on this rather than on text keeps
# working when the wording changes and never matches a program that merely
# mentions the word "crash" in its own logs.
readonly COREDUMP_MESSAGE_ID='fc2e22bc6ee647b6b90729ab34a250b1'
command -v journalctl >/dev/null 2>&1 || exit 0
command -v notify-send >/dev/null 2>&1 || exit 0
# The shell owns org.freedesktop.Notifications, and the crash most worth
# reporting is the one that took the shell with it. Waiting means that report
# arrives rather than vanishing into a bus nobody is serving.
for _ in $(seq 1 60); do
busctl --user status org.freedesktop.Notifications >/dev/null 2>&1 && break
sleep 1
done
# Read per crash rather than once at startup, so choosing an agent in Settings
# takes effect on the next crash instead of on the next login. This service runs
# for the life of the session; nothing restarts it when a preference changes.
read_setting() {
local key="$1" fallback="$2"
[[ -r "$SETTINGS" ]] || { printf '%s' "$fallback"; return; }
command -v jq >/dev/null 2>&1 || { printf '%s' "$fallback"; return; }
jq -r --arg k "$key" --arg d "$fallback" \
'if has($k) and (.[$k] != null) then (.[$k] | tostring) else $d end' \
"$SETTINGS" 2>/dev/null || printf '%s' "$fallback"
}
# What to call the agent in a sentence aimed at a person.
agent_label() {
case "$1" in
claude) printf 'Claude Code' ;;
codex) printf 'Codex' ;;
*) printf '%s' "$1" ;;
esac
}
declare -A reported=()
# -f from now, not from the boot: a session that starts after a crash should
# not open with a notification about something the user has already lived
# through and cannot act on.
journalctl --user -f -n 0 --output=json MESSAGE_ID="$COREDUMP_MESSAGE_ID" 2>/dev/null \
| while IFS= read -r line; do
[[ -n "$line" ]] || continue
# One jq per entry rather than one per field: the fields are read
# together, and the click payload needs all of them.
IFS=$'\t' read -r uid exe comm pid signal < <(
jq -r '[(.COREDUMP_UID // ""),
(.COREDUMP_EXE // ""),
(.COREDUMP_COMM // ""),
(.COREDUMP_PID // ""),
(.COREDUMP_SIGNAL_NAME // "")] | @tsv' <<<"$line" 2>/dev/null
)
# Another user's crash is not this session's business, and reporting it
# would leak what they are running.
[[ "$uid" == "$(id -u)" ]] || continue
[[ -n "$exe" || -n "$comm" ]] || continue
# The executable name first: COREDUMP_COMM is the kernel's comm field
# and is truncated to fifteen characters, so it reports
# "panama-test-cra" for a program called panama-test-crasher.
if [[ -n "$exe" ]]; then
program="$(basename "$exe")"
else
program="$comm"
fi
# Never announce our own machinery. A crash watcher that notifies about
# the crash watcher, or about the agent it just launched to investigate
# the last crash, is a loop with a toast in it.
[[ "$program" == panama-crash-* || "$program" == panama-agent* ]] && continue
[[ -z "${reported[$program]:-}" ]] || continue
reported[$program]=1
# The toast can only offer a diagnosis if there is something to diagnose
# with. No agent, or the offer switched off, and it stays exactly the
# actionless notification it has always been.
agent="$(read_setting preferredAgent none)"
offer="$(read_setting crashDiagnoseOffer true)"
if [[ -n "$agent" && "$agent" != "none" && "$offer" != "false" && "$pid" =~ ^[0-9]+$ ]]; then
# By absolute path, not by name. The shell runs this hint, and the
# shell is started by systemd -- whose environment does not carry
# the repository's bin directory on PATH, so a bare name would
# click into "command not found".
exec_command="$(printf '%q %q %q %q %q' \
"$PANAMA_PATH/bin/panama-agent-crash" \
"$pid" "${comm:-$program}" "${exe:-unknown}" "${signal:-unknown}")"
# The hint is data, not privilege. Any process on this session bus
# could send one, and running it grants nothing a local process
# could not already do for itself.
# Same urgency as the plain report, deliberately. Making the
# clickable one critical would let a crash break through Do Not
# Disturb, which is a louder desktop than anybody asked for in
# exchange for an offer that keeps until it is read anyway.
notify-send --icon=dialog-error-symbolic --app-name=Panama \
--hint="string:panama-exec:$exec_command" \
"$program stopped unexpectedly" \
"Click to diagnose with $(agent_label "$agent")." \
2>/dev/null || true
else
notify-send --icon=dialog-error-symbolic --app-name=Panama \
"$program stopped unexpectedly" \
"It crashed and was not able to recover. System Health has the details." \
2>/dev/null || true
fi
done
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Stamps a new migration, so the name is never chosen by hand.
#
# panama-dev-migration "remove the stale settings-ssh-keys.sh launcher command"
#
# The filename is the commit timestamp of HEAD, which makes glob order
# chronological without a sequence number that two branches could pick at the
# same time. Two migrations authored against the same commit would collide, so
# a taken name gets the next free second rather than silently overwriting.
#
# Developer tool, not part of any install path. See bin/panama-migrate for what
# runs these and what rules they have to follow.
set -euo pipefail
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
describe="${1:-}"
if [[ -z "$describe" ]]; then
echo 'usage: panama-dev-migration "what this repairs"' >&2
exit 2
fi
stamp="$(git -C "$PANAMA_PATH" log -1 --format=%cd --date=unix 2>/dev/null || date +%s)"
mkdir -p "$MIGRATIONS_DIR"
while [[ -e "$MIGRATIONS_DIR/$stamp.sh" ]]; do
stamp=$(( stamp + 1 ))
done
file="$MIGRATIONS_DIR/$stamp.sh"
cat >"$file" <<EOF
#!/usr/bin/env bash
# $describe
#
# Rules, because the runner cannot enforce them:
#
# * Safe to run twice. The marker records success, not intent.
# * Tolerant of the repair already being correct -- the user may have fixed
# it by hand, or a later ./install may have put it back.
# * Root work goes through \`panama-sudo --reason "..."\`, never bare sudo,
# so the password prompt names the repair.
# * Exit non-zero to be retried at the next login. Exit zero only when the
# machine is genuinely in the state this describes.
set -euo pipefail
PANAMA_PATH="\${PANAMA_PATH:-\$HOME/.local/share/Panama}"
# ... the repair goes here.
EOF
chmod +x "$file"
printf 'Created %s\n' "$file"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# The pressure valve.
#
# panama-hook theme-set dark orchid
#
# Runs ~/.config/panama/hooks/<name> and everything executable in
# ~/.config/panama/hooks/<name>.d/, in sorted order, with the hook's arguments.
#
# This exists so "can Panama also do X when the theme changes" is a five-line
# file somebody drops in a directory rather than a fork, a feature request, or
# a patch that has to be rebased forever. docs/UPSTREAM-INSPIRATION.md defers a
# plugin host as premature and still should: this is the thirty-line version
# that covers most of what people actually want from one, and it has no API to
# keep stable beyond "we will run your script and tell you what happened".
#
# A failing hook is reported and stepped over. Somebody's broken script must
# never break a theme change, an upgrade, or a login -- which is exactly what
# would happen if this used `set -e` and the caller did too.
#
# Hooks run synchronously, so a slow one delays whatever called it. That is
# deliberate: the alternative is a hook whose output arrives after the thing it
# was reacting to has already finished, which is harder to reason about than a
# pause.
set -uo pipefail
HOOK_DIR="${PANAMA_HOOK_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/panama/hooks}"
name="${1:-}"
if [[ -z "$name" ]]; then
echo 'usage: panama-hook <name> [args...]' >&2
exit 2
fi
shift
# A hook name reaches the filesystem, so it cannot be allowed to leave the
# directory. Callers are all in-repo today, which is exactly when this is
# cheap to add and easy to forget.
if [[ ! "$name" =~ ^[a-z][a-z0-9-]*$ ]]; then
echo "panama-hook: refusing hook name: $name" >&2
exit 2
fi
run_one() {
local script="$1"
# Shifted off before the arguments are forwarded, or every hook receives
# its own path as $1 and the real arguments arrive one place late.
shift
[[ -f "$script" && -x "$script" ]] || return 0
if ! "$script" "$@"; then
printf 'panama-hook: %s failed (%s); continuing\n' \
"$(basename "$script")" "$name" >&2
fi
}
# The single file first, then the .d directory in sorted order. Both are
# optional and having neither is the normal case.
run_one "$HOOK_DIR/$name" "$@"
if [[ -d "$HOOK_DIR/$name.d" ]]; then
while IFS= read -r script; do
[[ -n "$script" ]] || continue
run_one "$script" "$@"
done < <(find "$HOOK_DIR/$name.d" -maxdepth 1 -type f | sort)
fi
exit 0
Executable
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# What this machine is, asked one yes-or-no question at a time.
#
# panama-hw laptop && echo "portable"
# panama-hw clamshell && panama-lid close
#
# Every subcommand exits 0 for yes and 1 for no, prints nothing, and answers
# correctly on a machine that has none of the hardware in question. That last
# part is the whole point: a desktop must be able to ask "am I in clamshell
# mode" and get a calm no rather than an error, because the scripts and
# services that ask are shared between machines.
#
# `--json` answers everything at once, for the health page and for contracts.
#
# Detection reads sysfs directly rather than shelling out to lspci or upower:
# lspci touches PCI config space and wakes a runtime-suspended GPU, which is a
# real cost to pay for a question asked at every login.
#
# Paths are overridable (PANAMA_HW_SYS, PANAMA_HW_ACPI) so the contract can
# drive fixture trees. Nothing else should set them.
set -uo pipefail
SYS="${PANAMA_HW_SYS:-/sys}"
ACPI="${PANAMA_HW_ACPI:-/proc/acpi}"
# SMBIOS chassis types that mean "carried around": Portable, Laptop, Notebook,
# Hand Held, Sub Notebook, Tablet, Convertible, Detachable. A machine that
# reports something else, or reports nothing, is treated as stationary --
# guessing "laptop" on an unknown chassis would put battery chrome on a desktop.
readonly PORTABLE_CHASSIS=" 8 9 10 11 14 30 31 32 "
is_laptop() {
local type_file="$SYS/class/dmi/id/chassis_type" chassis
[[ -r "$type_file" ]] || return 1
chassis="$(cat "$type_file" 2>/dev/null)" || return 1
[[ "$PORTABLE_CHASSIS" == *" $chassis "* ]]
}
# The first SYSTEM battery, or nothing. Named rather than assumed to be BAT0:
# the second battery in a ThinkPad is BAT1, and a machine with only BAT1
# exists. The scope check is what keeps a desktop a desktop: a wireless mouse
# or a game controller publishes type=Battery with scope=Device, and counting
# one turned a tower into a "laptop" whose battery readout was the mouse's.
battery_path() {
local supply type scope
for supply in "$SYS"/class/power_supply/*; do
[[ -r "$supply/type" ]] || continue
type="$(cat "$supply/type" 2>/dev/null)"
[[ "$type" == "Battery" ]] || continue
scope="$(cat "$supply/scope" 2>/dev/null || echo System)"
[[ "$scope" == "Device" ]] && continue
printf '%s\n' "$supply"
return 0
done
return 1
}
has_battery() { battery_path >/dev/null; }
# On wall power. A machine with no mains supply at all and no system battery
# is a desktop, and a desktop is always on wall power -- answering "no" there
# would make every battery-aware timing apply to a machine that cannot run
# out of power. But "no Mains" alone is not "desktop": hardware charged only
# over USB-PD exposes type=USB supplies and no Mains at all, and reading that
# as permanently-on-AC meant its battery timings never engaged while it ran
# down. When no Mains exists but a system battery does, the battery's own
# status is the answer: Discharging means battery, everything else means fed.
on_ac() {
local supply type online found=1 battery status
for supply in "$SYS"/class/power_supply/*; do
[[ -r "$supply/type" ]] || continue
type="$(cat "$supply/type" 2>/dev/null)"
[[ "$type" == "Mains" ]] || continue
found=0
online="$(cat "$supply/online" 2>/dev/null || echo 0)"
[[ "$online" == "1" ]] && return 0
done
# Mains exists and none of it is online: genuinely on battery.
(( found == 0 )) && return 1
if battery="$(battery_path)"; then
status="$(cat "$battery/status" 2>/dev/null || echo Unknown)"
[[ "$status" == "Discharging" ]] && return 1
fi
return 0
}
# ACPI first, logind second. Some platforms expose the lid only as an evdev
# switch with no /proc/acpi/button entry; logind watches the switch either
# way, so its LidClosed property is the fallback that keeps clamshell
# detection honest there. No logind (a container, a test tree) means the
# fallback quietly answers open, which is the safe direction.
lid_closed() {
local state
for state in "$ACPI"/button/lid/*/state; do
[[ -r "$state" ]] || continue
grep -qi closed "$state" && return 0
return 1
done
[[ -d "$ACPI/button/lid" ]] && return 1
busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
org.freedesktop.login1.Manager LidClosed 2>/dev/null | grep -q 'b true'
}
# A connected output that is not the built-in panel. eDP, LVDS and DSI are the
# internal ones; everything else arrived through a cable.
has_external_monitor() {
local status connector
for status in "$SYS"/class/drm/card*-*/status; do
[[ -r "$status" ]] || continue
[[ "$(cat "$status" 2>/dev/null)" == "connected" ]] || continue
connector="$(basename "$(dirname "$status")")"
case "$connector" in
*eDP*|*LVDS*|*DSI*) continue ;;
*) return 0 ;;
esac
done
return 1
}
# The one definition the rest of the laptop work hangs on: the lid is shut and
# there is still a screen to use. Closing the lid on a dock must not suspend;
# closing it on a train must.
is_clamshell() { lid_closed && has_external_monitor; }
has_touchpad() {
local name
for name in "$SYS"/class/input/*/name; do
[[ -r "$name" ]] || continue
grep -qi touchpad "$name" && return 0
done
return 1
}
# Vendor 0x10de on a display-class device. Read from sysfs rather than lspci
# so an idle discrete GPU is not woken to answer.
has_nvidia() {
local device vendor class
for device in "$SYS"/bus/pci/devices/*; do
[[ -r "$device/vendor" && -r "$device/class" ]] || continue
vendor="$(cat "$device/vendor" 2>/dev/null)"
[[ "$vendor" == "0x10de" ]] || continue
class="$(cat "$device/class" 2>/dev/null)"
[[ "$class" == 0x03* ]] && return 0
done
return 1
}
answer() { "$1" && printf 'true' || printf 'false'; }
cmd_json() {
printf '{"laptop":%s,"battery":%s,"ac":%s,"lidClosed":%s,"externalMonitor":%s,"clamshell":%s,"touchpad":%s,"nvidia":%s}\n' \
"$(answer is_laptop)" "$(answer has_battery)" "$(answer on_ac)" \
"$(answer lid_closed)" "$(answer has_external_monitor)" \
"$(answer is_clamshell)" "$(answer has_touchpad)" "$(answer has_nvidia)"
}
case "${1:-}" in
laptop) is_laptop ;;
battery) has_battery ;;
battery-path) battery_path ;;
ac) on_ac ;;
lid-closed) lid_closed ;;
external-monitor) has_external_monitor ;;
clamshell) is_clamshell ;;
touchpad) has_touchpad ;;
nvidia) has_nvidia ;;
--json) cmd_json ;;
-h|--help|"")
cat <<'USAGE'
usage: panama-hw <predicate>
Exits 0 for yes, 1 for no, and prints nothing.
laptop a portable chassis
battery a battery is present
battery-path print the first battery's sysfs path (0 if found)
ac on wall power (a machine with no mains is always yes)
lid-closed the lid is shut
external-monitor a connected output that is not the built-in panel
clamshell lid shut AND an external monitor: docked, keep working
touchpad a touchpad is present
nvidia an NVIDIA display device is present
--json every answer at once
USAGE
;;
*) printf 'panama-hw: unknown predicate: %s\n' "$1" >&2; exit 2 ;;
esac
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Focus the window if it is already open; start it if it is not.
#
# panama-launch --class '^helium$' -- helium-browser-bin
# panama-launch --class '^kitty$' --title 'nvim' -- kitty nvim .
#
# This is what the application keys do on every other desktop. Pressing the
# browser key twice on macOS or Windows raises the browser; here it used to
# open a second one, which is the single most common "Linux feels wrong"
# moment and a twenty-line fix.
#
# Matching is a regular expression against the window class, optionally
# narrowed by title. Both halves matter: the terminal and the editor are both
# kitty on this desktop, and only the title tells them apart -- so a class-only
# match would make the editor key raise whatever terminal happened to be open.
#
# Anchor your patterns. `--class mail` would match `gmail-notifier`, and the
# key that should open Thunderbird would raise somebody's notifier instead.
set -uo pipefail
class_pattern=""
title_pattern=""
while (( $# > 0 )); do
case "$1" in
--class) class_pattern="${2:-}"; shift 2 ;;
--title) title_pattern="${2:-}"; shift 2 ;;
--) shift; break ;;
*) break ;;
esac
done
if [[ -z "$class_pattern" || $# -eq 0 ]]; then
echo 'usage: panama-launch --class <regex> [--title <regex>] -- command [args...]' >&2
exit 2
fi
launch() {
# setsid so the application outlives this script and is not a child of the
# compositor's exec, which would tie its lifetime to a shell that exits.
setsid "$@" >/dev/null 2>&1 &
exit 0
}
# No compositor, no window list: just start the thing.
command -v hyprctl >/dev/null 2>&1 || launch "$@"
address="$(hyprctl clients -j 2>/dev/null | jq -r --arg class "$class_pattern" --arg title "$title_pattern" '
[ .[]
| select(.mapped)
| select(.class | test($class))
| select($title == "" or (.title | test($title)))
]
# Most recently focused first: with several matches, raise the one the
# user was last in rather than whichever the compositor lists first.
| sort_by(-.focusHistoryID)
| .[0].address // empty
' 2>/dev/null)"
if [[ -n "$address" ]]; then
# Hyprland 0.56 dispatches through Lua: `hyprctl dispatch focuswindow
# address:0x...` is parsed as Lua source and fails. The selector string is
# what hl.focus accepts; a table of the same fields is refused.
exec hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })"
fi
launch "$@"
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# Bringing an already-installed machine up to what this checkout expects.
#
# `./install` is additive: it copies files over `/`, links dotfiles, and
# installs packages. It has no way to say "remove that file", "disable that
# unit", "that symlink now points at the wrong place". So a machine installed
# in March keeps March's mistakes forever, and the only thing that ever fixes
# them is the person who happens to read a commit message.
#
# A migration is one shell script that performs one such repair, exactly once,
# on machines that need it.
#
# migrations/<unix-timestamp>.sh
#
# The name is the commit timestamp of HEAD when it was authored, so glob order
# over fixed-width epoch seconds IS chronological order -- no sequence numbers
# to collide on across branches. `panama-dev-migration` stamps them.
#
# State is one empty marker file per migration under
# $XDG_STATE_HOME/panama/migrations. Present means applied. There is no
# database and no version integer, because the failure mode of a version
# integer is that one bad migration strands every later one behind it.
#
# The rules a migration must follow are in the template that
# `panama-dev-migration` writes, and they are worth repeating here because
# this runner cannot enforce them:
#
# * Safe to run twice. The marker only records that it succeeded once.
# * Tolerant of the repair already being correct -- a user may have fixed it
# by hand, or a later `./install` may have overwritten it back.
# * Root work goes through `panama-sudo --reason "..."`, never bare sudo,
# so the prompt names the repair. See bin/panama-sudo.
#
# The marker is written ONLY on success, so a migration that fails stays
# pending and is retried at the next login. That is deliberate: a repair that
# could not complete has not happened, and recording it as done would hide it
# forever.
#
# This mirrors config/dot/quickshell/config/Migrations.qml, which does the same
# job for the settings JSON and documents the same reasoning. That one handles
# renamed preference keys; this one handles everything else.
set -uo pipefail
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
MIGRATIONS_DIR="$PANAMA_PATH/migrations"
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/panama/migrations"
export PANAMA_PATH
info() { printf '\033[1;34m==>\033[0m %s\n' "$*"; }
ok() { printf '\033[1;32m✓\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m!\033[0m %s\n' "$*" >&2; }
err() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; }
# Every migration this checkout ships, oldest first. Empty is a valid state.
all_migrations() {
[[ -d "$MIGRATIONS_DIR" ]] || return 0
local file
for file in "$MIGRATIONS_DIR"/*.sh; do
[[ -e "$file" ]] || continue
basename "$file"
done | sort
}
pending_migrations() {
local name
while read -r name; do
[[ -n "$name" ]] || continue
[[ -e "$STATE_DIR/$name" ]] || printf '%s\n' "$name"
done < <(all_migrations)
}
run_one() {
local name="$1" file="$MIGRATIONS_DIR/$1"
info "$name"
# A subshell with its own strictness: a migration that forgets `set -e` is
# still stopped by its first failing command, and one that sets shell
# options cannot leak them into the next migration.
if bash -euo pipefail "$file"; then
mkdir -p "$STATE_DIR"
: >"$STATE_DIR/$name"
ok "$name applied"
return 0
fi
err "$name failed and will be retried at the next login"
return 1
}
cmd_run() {
local pending
pending="$(pending_migrations)"
if [[ -z "$pending" ]]; then
ok "Nothing to migrate; this machine matches the checkout."
return 0
fi
local count failed=0 name
count="$(grep -c . <<<"$pending")"
info "$count migration(s) to apply"
while read -r name; do
[[ -n "$name" ]] || continue
# Stop at the first failure rather than continuing. Migrations are
# ordered, and a later one may assume an earlier one landed; running
# it anyway turns one stuck repair into an unpredictable machine.
if ! run_one "$name"; then
failed=1
break
fi
done <<<"$pending"
if (( failed )); then
warn "Re-running 'panama migrate' is safe and will retry from the failure."
warn "If it keeps failing, hand it to an agent: panama diagnose"
return 1
fi
ok "This machine now matches the checkout."
# Only after repairs actually ran: a hook that fires on every login when
# there was nothing to do is a hook people disable.
"$PANAMA_PATH/bin/panama-hook" post-migrate || true
}
# The check the login notifier runs. Exit 0 means work is waiting, so it reads
# as `if panama-migrate --pending; then notify; fi`.
cmd_pending() {
local pending
pending="$(pending_migrations)"
[[ -n "$pending" ]] || return 1
grep -c . <<<"$pending"
}
cmd_list() {
local name
while read -r name; do
[[ -n "$name" ]] || continue
if [[ -e "$STATE_DIR/$name" ]]; then
printf 'applied %s\n' "$name"
else
printf 'pending %s\n' "$name"
fi
done < <(all_migrations)
}
# Re-run one that already succeeded. For developing a migration, and for the
# rare case where a repair was undone by something else.
cmd_force() {
local name="${1:-}"
[[ -n "$name" ]] || { err "force needs a migration name"; return 2; }
[[ -e "$MIGRATIONS_DIR/$name" ]] || { err "no such migration: $name"; return 2; }
rm -f "$STATE_DIR/$name"
run_one "$name"
}
# Mark everything applied without running it. This is what a fresh install
# does: the machine was just built from this checkout, so every repair those
# migrations describe is already true of it, and running them would apply
# fixes for versions it never had.
cmd_baseline() {
mkdir -p "$STATE_DIR"
local name count=0
while read -r name; do
[[ -n "$name" ]] || continue
[[ -e "$STATE_DIR/$name" ]] && continue
: >"$STATE_DIR/$name"
count=$(( count + 1 ))
done < <(all_migrations)
ok "Marked $count migration(s) as already applied."
}
case "${1:-run}" in
run) shift || true; cmd_run "$@" ;;
--pending) shift || true; cmd_pending "$@" ;;
--list|list) shift || true; cmd_list "$@" ;;
--force) shift || true; cmd_force "$@" ;;
--baseline) shift || true; cmd_baseline "$@" ;;
-h|--help)
cat <<'USAGE'
usage: panama-migrate [run|--pending|--list|--force <name>|--baseline]
run Apply every pending migration, oldest first (default)
--pending Exit 0 and print the count when work is waiting, else exit 1
--list Show every migration and whether it has been applied
--force Re-run one migration that already succeeded
--baseline Mark everything applied without running it (fresh installs)
USAGE
;;
*) err "unknown argument: $1"; exit 2 ;;
esac
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# "Panama has repairs waiting for this machine."
#
# A migration that nobody knows about is a migration that never runs. This
# checks once per session and, when work is pending, sends one notification
# whose action opens a terminal running `panama migrate` -- so the repair is
# always something the user chose, never something that happened to them.
#
# Waiting for the notification server first is not politeness. Quickshell owns
# org.freedesktop.Notifications, and a shell that has not started yet would
# swallow this silently -- which is precisely the session where a pending
# migration is most likely to matter.
set -uo pipefail
PANAMA_PATH="${PANAMA_PATH:-$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)}"
TERMINAL="${PANAMA_TERMINAL:-kitty}"
command -v notify-send >/dev/null 2>&1 || exit 1
count="$("$PANAMA_PATH/bin/panama-migrate" --pending)" || exit 1
[[ -n "$count" ]] || exit 1
# Up to ~15s for the shell to claim the bus name. Longer than a healthy start
# needs, short enough that a session without a shell gives up rather than
# lingering.
for _ in $(seq 1 30); do
if busctl --user status org.freedesktop.Notifications >/dev/null 2>&1; then
break
fi
sleep 0.5
done
if (( count == 1 )); then
body="One repair is waiting for this machine."
else
body="$count repairs are waiting for this machine."
fi
# Critical so it waits to be read: a notification that expires while the user
# is elsewhere has told nobody anything. The action is the whole point -- there
# is no instruction to remember and nothing to type.
action="$(notify-send --urgency=critical --icon=system-software-update \
--app-name=Panama \
--action=migrate="Apply now" --action=later="Later" \
"Panama updates" "$body" 2>/dev/null)" || exit 0
[[ "$action" == "migrate" ]] || exit 0
exec "$TERMINAL" --hold "$PANAMA_PATH/bin/panama-migrate" run
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env bash
#
# panama-server the compose services a server machine runs.
#
# The repository carries the catalog: server/containers/<Name>/ holds a
# compose.yml, a podman-<name>.service user unit, and an .env.example naming
# what the service needs told. The machine carries the rest: ~/Server/<Name>/
# is a real directory owning the .env (secrets never sit inside the checkout,
# gitignored or not) and the bind-mounted data. Only the tracked files are
# symlinked, so a `git clean` in the repo can never reach a database.
#
# list every service in the catalog, with its state here
# enable <Name> link it into ~/Server, seed .env, enable the unit
# disable <Name> stop it and remove the unit; data and .env stay put
# status [<Name>] what is actually running
# relink refresh the symlinks for everything enabled here
#
# Verbs act on the unit and the links, never on data: there is deliberately no
# verb here that deletes ~/Server/<Name> or anything in it.
set -uo pipefail
PROGRAM="panama server"
SCRIPT_PATH=$(readlink -f "${BASH_SOURCE[0]}")
PANAMA_DIR=$(cd "$(dirname "$SCRIPT_PATH")/.." && pwd)
# Overridable so the contract can point this at a fixture catalog and a
# throwaway HOME rather than the machine's real services.
SERVER_DIR="${PANAMA_SERVER_DIR:-$PANAMA_DIR/server/containers}"
TARGET_DIR="${PANAMA_SERVER_TARGET:-$HOME/Server}"
UNIT_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
STATE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/panama/server-definitions"
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ $(tput colors 2>/dev/null || echo 0) -ge 8 ]]; then
BOLD=$(tput bold); RESET=$(tput sgr0)
RED=$(tput setaf 1); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); BLUE=$(tput setaf 4)
else
BOLD=""; RESET=""; RED=""; GREEN=""; YELLOW=""; BLUE=""
fi
info() { printf '%s==>%s %s\n' "${BLUE}${BOLD}" "$RESET" "$*"; }
ok() { printf '%s✓%s %s\n' "${GREEN}${BOLD}" "$RESET" "$*"; }
warn() { printf '%s!%s %s\n' "${YELLOW}${BOLD}" "$RESET" "$*"; }
err() { printf '%s✗%s %s\n' "${RED}${BOLD}" "$RESET" "$*" >&2; }
usage() {
cat <<EOF
${BOLD}$PROGRAM${RESET} manage the compose services in server/containers/
${BOLD}Usage:${RESET}
$PROGRAM list Every service in the catalog, and its state here
$PROGRAM enable <Name> Link a service into ~/Server, seed its .env from
.env.example, and enable its unit. Stops short of
starting when the .env still holds CHANGE_ME
placeholders, and says which.
$PROGRAM disable <Name> Stop the unit and unlink it. ~/Server/<Name>,
its .env and its data are never touched.
$PROGRAM status [<Name>] What is actually running, per service
$PROGRAM relink Refresh every enabled service's symlinks after a
pull, and name the ones whose definitions changed
(nothing is restarted for you)
EOF
}
# ── The catalog ──────────────────────────────────────────────────────────────
services() {
local dir
for dir in "$SERVER_DIR"/*/; do
[[ -d "$dir" ]] || continue
basename "$dir"
done
}
require_service() {
local name="$1"
if [[ ! -d "$SERVER_DIR/$name" ]]; then
err "No such service: '$name'"
printf 'The catalog has:\n' >&2
services | sed 's/^/ /' >&2
exit 1
fi
}
# The one unit file a service directory carries. Its filename is its identity
# -- podman-<name>.service, exactly what lands in the systemd user directory --
# so nothing here invents a name that could drift from the file's.
unit_path_for() {
local name="$1" unit
for unit in "$SERVER_DIR/$name"/*.service; do
[[ -e "$unit" ]] || continue
printf '%s' "$unit"
return 0
done
return 1
}
unit_name_for() {
local unit
unit="$(unit_path_for "$1")" || return 1
basename "$unit"
}
# Installed means our symlink is in the systemd user directory. A regular file
# of the same name is a pre-Panama install done by hand -- reported, never
# silently replaced.
unit_installed() {
local unit_name
unit_name="$(unit_name_for "$1")" || return 1
[[ -L "$UNIT_DIR/$unit_name" ]]
}
definition_hash() {
local name="$1" unit
unit="$(unit_path_for "$name")" || unit=/dev/null
cat "$SERVER_DIR/$name/compose.yml" "$unit" 2>/dev/null | sha256sum | cut -d' ' -f1
}
recorded_hash() {
[[ -r "$STATE_FILE" ]] || return 0
awk -v name="$1" '$1 == name {print $2}' "$STATE_FILE"
}
record_hash() {
local name="$1" hash="$2"
mkdir -p "$(dirname "$STATE_FILE")"
{ [[ -r "$STATE_FILE" ]] && awk -v name="$name" '$1 != name' "$STATE_FILE"; \
printf '%s %s\n' "$name" "$hash"; } >"$STATE_FILE.tmp"
mv "$STATE_FILE.tmp" "$STATE_FILE"
}
# Replace path with a symlink to src. An existing regular file is moved aside
# with its name intact plus a suffix, because the only regular file that can be
# here is a pre-Panama one somebody wrote by hand -- worth keeping to diff.
link_file() {
local src="$1" path="$2"
if [[ -L "$path" ]]; then
[[ "$(readlink -f "$path")" == "$(readlink -f "$src")" ]] && return 0
rm "$path"
elif [[ -e "$path" ]]; then
mv "$path" "$path.pre-panama"
warn "Kept the existing $(basename "$path") as $(basename "$path").pre-panama"
fi
ln -s "$src" "$path"
}
# ── Verbs ────────────────────────────────────────────────────────────────────
cmd_list() {
local name unit_name state enabled
for name in $(services); do
unit_name="$(unit_name_for "$name")" || { printf '%-24s %s\n' "$name" "no unit file"; continue; }
if unit_installed "$name"; then
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
enabled="enabled"
[[ "$state" == active ]] && state="${GREEN}active${RESET}" || state="${YELLOW}${state:-unknown}${RESET}"
printf '%-24s %s, %b\n' "$name" "$enabled" "$state"
elif [[ -e "$UNIT_DIR/$unit_name" ]]; then
printf '%-24s %s\n' "$name" "installed by hand (not Panama's symlink)"
else
printf '%-24s %s\n' "$name" "-"
fi
done
}
cmd_enable() {
local name="${1:-}"
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM enable <Name>"; exit 1; }
require_service "$name"
local repo_dir="$SERVER_DIR/$name" live_dir="$TARGET_DIR/$name"
[[ -f "$repo_dir/compose.yml" ]] || { err "$name has no compose.yml in the catalog"; exit 1; }
local unit_path unit_name
unit_path="$(unit_path_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
unit_name="$(basename "$unit_path")"
mkdir -p "$live_dir"
link_file "$repo_dir/compose.yml" "$live_dir/compose.yml"
ok "Linked compose.yml → $live_dir/compose.yml"
# The .env lives with the machine, not the checkout. Seeded once from the
# example and never overwritten -- it is where the person's secrets go.
if [[ -f "$repo_dir/.env.example" && ! -e "$live_dir/.env" ]]; then
cp "$repo_dir/.env.example" "$live_dir/.env"
chmod 600 "$live_dir/.env"
ok "Seeded $live_dir/.env from .env.example"
fi
# Refusing to start on placeholders, and saying which: a service brought up
# with CHANGE_ME as its database password does not fail loudly, it runs --
# and what it runs is a service whose password is CHANGE_ME.
if [[ -e "$live_dir/.env" ]] && grep -q 'CHANGE_ME' "$live_dir/.env"; then
warn "$live_dir/.env still has placeholder values:"
grep -n 'CHANGE_ME' "$live_dir/.env" | sed 's/^/ /'
printf 'Fill them in, then run: %s enable %s\n' "$PROGRAM" "$name"
exit 1
fi
mkdir -p "$UNIT_DIR"
link_file "$unit_path" "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
if systemctl --user enable --now "$unit_name"; then
record_hash "$name" "$(definition_hash "$name")"
ok "$name enabled and started ($unit_name)"
else
err "$unit_name did not start; see: systemctl --user status $unit_name"
exit 1
fi
}
cmd_disable() {
local name="${1:-}"
[[ -n "$name" ]] || { err "Which service? Usage: $PROGRAM disable <Name>"; exit 1; }
require_service "$name"
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file in the catalog"; exit 1; }
systemctl --user disable --now "$unit_name" 2>/dev/null || true
if [[ -L "$UNIT_DIR/$unit_name" ]]; then
rm "$UNIT_DIR/$unit_name"
systemctl --user daemon-reload
fi
ok "$name disabled. ~/Server/$name, its .env and its data were not touched."
}
cmd_status() {
local name="${1:-}"
if [[ -n "$name" ]]; then
require_service "$name"
local unit_name
unit_name="$(unit_name_for "$name")" || { err "$name has no unit file"; exit 1; }
systemctl --user status --no-pager "$unit_name" || true
if [[ -d "$TARGET_DIR/$name" ]]; then
( cd "$TARGET_DIR/$name" && podman compose ps 2>/dev/null ) || true
fi
return 0
fi
# The role's health summary: what is enabled here, and is it actually up.
local any=0 unit_name state
for name in $(services); do
unit_installed "$name" || continue
any=1
unit_name="$(unit_name_for "$name")"
state="$(systemctl --user is-active "$unit_name" 2>/dev/null || true)"
if [[ "$state" == active ]]; then
ok "$name"
else
err "$name is ${state:-unknown}"
fi
done
(( any )) || info "No services enabled on this machine yet. See: $PROGRAM list"
return 0
}
cmd_relink() {
local name unit_path unit_name changed=() unmanaged=() refreshed=0
for name in $(services); do
unit_path="$(unit_path_for "$name")" || continue
unit_name="$(basename "$unit_path")"
if [[ -e "$UNIT_DIR/$unit_name" && ! -L "$UNIT_DIR/$unit_name" ]]; then
unmanaged+=("$name")
continue
fi
unit_installed "$name" || continue
link_file "$unit_path" "$UNIT_DIR/$unit_name"
mkdir -p "$TARGET_DIR/$name"
link_file "$SERVER_DIR/$name/compose.yml" "$TARGET_DIR/$name/compose.yml"
refreshed=$((refreshed + 1))
# Changed since the last time this machine looked, which is what makes a
# pull actionable: the restart is deliberately yours to run, so the least
# this can do is say which services are running a definition that is no
# longer what the repository says.
local now
now="$(definition_hash "$name")"
if [[ "$(recorded_hash "$name")" != "$now" ]]; then
changed+=("$name")
record_hash "$name" "$now"
fi
done
systemctl --user daemon-reload 2>/dev/null || true
info "Relinked $refreshed enabled service(s)"
if (( ${#unmanaged[@]} > 0 )); then
warn "Installed by hand, left alone: ${unmanaged[*]}"
fi
if (( ${#changed[@]} > 0 )); then
warn "Definitions changed; restart each when ready:"
for name in "${changed[@]}"; do
printf ' %s: systemctl --user restart %s\n' "$name" "$(unit_name_for "$name")"
done
fi
}
# ── Dispatcher ───────────────────────────────────────────────────────────────
main() {
if [[ ! -d "$SERVER_DIR" ]]; then
err "No service catalog at $SERVER_DIR"
exit 1
fi
local cmd="${1:-}"
case "$cmd" in
list) shift; cmd_list "$@" ;;
enable) shift; cmd_enable "$@" ;;
disable) shift; cmd_disable "$@" ;;
status) shift; cmd_status "$@" ;;
relink) shift; cmd_relink "$@" ;;
help|-h|--help|"") usage ;;
*)
err "Unknown command: '$cmd'"
echo
usage
exit 1
;;
esac
}
main "$@"
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# pkexec, with a stated reason on the password prompt.
#
# panama-sudo --reason "Installing gamemode hooks" -- dnf install gamemode
#
# The reason travels to the shell over Quickshell IPC before pkexec runs, and
# Panama's prompt shows it clearly labeled beside polkitd's own action message
# -- beside, never instead of: anything can claim any reason, so the real
# action text stays the trust anchor. Meant for agents and scripts, so the
# person at the keyboard learns WHY before typing their password.
#
# Degrades to plain pkexec: no --reason, no running shell, or no qs on PATH
# all behave identically to calling pkexec yourself.
set -euo pipefail
reason=""
if [[ "${1:-}" == "--reason" ]]; then
reason="${2:?panama-sudo: --reason needs a value}"
shift 2
fi
[[ "${1:-}" == "--" ]] && shift
if (( $# == 0 )); then
echo 'usage: panama-sudo [--reason "why"] -- command [args...]' >&2
exit 2
fi
if [[ -n "$reason" ]] && command -v qs >/dev/null 2>&1; then
qs ipc call polkit reason "$reason" >/dev/null 2>&1 || true
fi
exec pkexec "$@"
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# Make a video smaller, or turn a picture into another format.
#
# panama-transcode video <file> 1080p|720p|480p
# panama-transcode image <file> jpg|png|webp
#
# The two things people actually right-click a media file to do. macOS has
# Quick Actions for both; Linux file managers have neither, and the usual
# answer is a web uploader or a forgotten ffmpeg incantation.
#
# Two rules, both about not losing work:
#
# * The input is never written to. Output goes beside it with a suffix.
# * An existing output is never overwritten. The suffix gains a number
# rather than replacing something somebody made earlier.
#
# ffmpeg does the work and is already a declared dependency; this only decides
# the arguments, which is the part worth writing down once.
set -uo pipefail
err() { printf 'panama-transcode: %s\n' "$*" >&2; }
command -v ffmpeg >/dev/null 2>&1 || { err 'ffmpeg is not installed'; exit 1; }
# A path that does not exist yet, beside the input.
free_path() {
local dir="$1" stem="$2" suffix="$3" ext="$4"
local candidate="$dir/$stem-$suffix.$ext"
local counter=2
while [[ -e "$candidate" ]]; do
candidate="$dir/$stem-$suffix-$counter.$ext"
counter=$(( counter + 1 ))
done
printf '%s' "$candidate"
}
notify() {
command -v notify-send >/dev/null 2>&1 || return 0
notify-send --icon="${3:-video-x-generic}" "$1" "$2" 2>/dev/null || true
}
cmd_video() {
local input="${1:-}" preset="${2:-1080p}"
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
local height
case "$preset" in
1080p) height=1080 ;;
720p) height=720 ;;
480p) height=480 ;;
*) err "unknown size: $preset"; return 2 ;;
esac
local dir stem output
dir="$(dirname "$input")"
stem="$(basename "${input%.*}")"
output="$(free_path "$dir" "$stem" "$preset" mp4)"
notify "Transcoding" "$(basename "$input") → $preset" video-x-generic
# -2 rather than -1 on width: H.264 needs even dimensions, and an odd one
# fails at the very end of a long encode.
if ffmpeg -nostdin -loglevel error -i "$input" \
-vf "scale=-2:'min($height,ih)'" \
-c:v libx264 -crf 23 -preset medium \
-c:a aac -b:a 128k \
"$output" </dev/null; then
notify "Transcoded" "$(basename "$output")" video-x-generic
printf '%s\n' "$output"
else
rm -f "$output"
notify "Transcode failed" "$(basename "$input")" dialog-error-symbolic
return 1
fi
}
cmd_image() {
local input="${1:-}" format="${2:-jpg}"
[[ -f "$input" ]] || { err 'that file does not exist'; return 2; }
case "$format" in
jpg|png|webp) ;;
*) err "unknown format: $format"; return 2 ;;
esac
local dir stem output
dir="$(dirname "$input")"
stem="$(basename "${input%.*}")"
output="$(free_path "$dir" "$stem" converted "$format")"
local -a quality=()
[[ "$format" == "jpg" ]] && quality=(-q:v 3)
[[ "$format" == "webp" ]] && quality=(-quality 82)
if ffmpeg -nostdin -loglevel error -i "$input" "${quality[@]}" "$output" </dev/null; then
notify "Converted" "$(basename "$output")" image-x-generic
printf '%s\n' "$output"
else
rm -f "$output"
notify "Conversion failed" "$(basename "$input")" dialog-error-symbolic
return 1
fi
}
case "${1:-}" in
video) shift; cmd_video "$@" ;;
image) shift; cmd_image "$@" ;;
-h|--help|"")
cat <<'USAGE'
usage: panama-transcode video <file> [1080p|720p|480p]
panama-transcode image <file> [jpg|png|webp]
Writes beside the input, never over it, and never over an existing output.
USAGE
;;
*) err "unknown command: $1"; exit 2 ;;
esac
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env bash
# A website, as an application.
#
# panama-webapp install https://app.example.com "Example"
# panama-webapp list
# panama-webapp remove "Example"
#
# Both macOS and Windows ship this now -- Safari's "Add to Dock", Edge's
# "Install this site as an app" -- and the dock and launcher here had nothing
# to feed them but installed packages. A web app gets its own icon, its own
# window with no browser chrome, and its own entry in the launcher, which is
# most of what "installed" means in practice.
#
# The desktop entry is ordinary and inspectable: it lives in
# ~/.local/share/applications with everything else, and `remove` finds its own
# entries by the launcher line rather than by keeping a list somewhere.
#
# Chromium-family browsers implement --app. Firefox does not, and there is no
# honest equivalent, so a machine whose default browser is Firefox is told
# rather than given something that opens a normal window and pretends.
set -uo pipefail
APPLICATIONS="${XDG_DATA_HOME:-$HOME/.local/share}/applications"
ICONS="${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor/256x256/apps"
LAUNCH_MARKER="panama-webapp"
err() { printf 'panama-webapp: %s\n' "$*" >&2; }
# A filename that cannot escape the applications directory. Everything that is
# not a letter or a digit becomes a hyphen, which also makes the result
# predictable enough for `remove` to find.
slugify() {
printf '%s' "$1" \
| tr '[:upper:]' '[:lower:]' \
| sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//'
}
# The browser to open the app window with. Follows the desktop's own default
# rather than naming one, and refuses rather than degrading when that browser
# cannot do app mode.
resolve_browser() {
local desktop exec_line binary
desktop="$(xdg-settings get default-web-browser 2>/dev/null || true)"
if [[ -n "$desktop" ]]; then
local file
for dir in "$APPLICATIONS" /usr/local/share/applications /usr/share/applications; do
file="$dir/$desktop"
[[ -r "$file" ]] || continue
exec_line="$(sed -n 's/^Exec=//p' "$file" | head -1)"
binary="$(awk '{ print $1 }' <<<"$exec_line")"
break
done
fi
[[ -n "${binary:-}" ]] || binary="$(command -v chromium || command -v google-chrome || true)"
[[ -n "$binary" ]] || return 1
# Chromium-family only. The name check is crude but the alternative is
# launching a browser to ask it, which is worse.
case "$(basename "$binary")" in
*firefox*|*librewolf*|*zen*) return 2 ;;
esac
printf '%s' "$binary"
}
# Four attempts, in descending order of how much the site had to say about it.
# An icon is never worth failing an install over: a web app with the generic
# icon still works.
fetch_icon() {
local url="$1" slug="$2" origin html href target
origin="$(sed -E 's#^(https?://[^/]+).*#\1#' <<<"$url")"
target="$ICONS/$slug.png"
mkdir -p "$ICONS"
html="$(curl -fsSL --max-time 10 "$url" 2>/dev/null || true)"
href="$(grep -oiE '<link[^>]+rel="[^"]*apple-touch-icon[^"]*"[^>]*>' <<<"$html" \
| grep -oiE 'href="[^"]+"' | head -1 | sed 's/href="//I; s/"$//' || true)"
if [[ -n "$href" ]]; then
case "$href" in
http*) ;;
/*) href="$origin$href" ;;
*) href="$origin/$href" ;;
esac
curl -fsSL --max-time 10 -o "$target" "$href" 2>/dev/null && { printf '%s' "$slug"; return 0; }
fi
curl -fsSL --max-time 10 -o "$target" "$origin/apple-touch-icon.png" 2>/dev/null \
&& { printf '%s' "$slug"; return 0; }
local host
host="$(sed -E 's#^https?://([^/]+).*#\1#' <<<"$url")"
curl -fsSL --max-time 10 -o "$target" \
"https://www.google.com/s2/favicons?sz=256&domain=$host" 2>/dev/null \
&& { printf '%s' "$slug"; return 0; }
rm -f "$target"
printf 'applications-internet'
}
cmd_install() {
local url="${1:-}" name="${2:-}"
[[ -n "$url" ]] || { err 'install needs a URL'; return 2; }
[[ "$url" =~ ^https?:// ]] || { err 'the URL must begin with http:// or https://'; return 2; }
# Default the name from the host, so `install https://app.example.com` is
# enough for the common case.
[[ -n "$name" ]] || name="$(sed -E 's#^https?://(www\.)?([^/]+).*#\2#' <<<"$url")"
local slug; slug="$(slugify "$name")"
[[ -n "$slug" ]] || { err 'that name has no usable characters in it'; return 2; }
local browser status
browser="$(resolve_browser)"; status=$?
if (( status == 2 )); then
err 'the default browser cannot open a site as its own application.'
err 'Chromium-family browsers implement --app; Firefox does not.'
return 1
fi
[[ -n "$browser" ]] || { err 'no browser found'; return 1; }
local icon; icon="$(fetch_icon "$url" "$slug")"
mkdir -p "$APPLICATIONS"
local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop"
cat >"$entry" <<ENTRY
[Desktop Entry]
Type=Application
Name=$name
Comment=$url
Exec=$browser --app=$url
Icon=$icon
Terminal=false
Categories=Network;
StartupWMClass=$browser
X-Panama-WebApp=$url
ENTRY
command -v update-desktop-database >/dev/null 2>&1 \
&& update-desktop-database "$APPLICATIONS" >/dev/null 2>&1
command -v gtk-update-icon-cache >/dev/null 2>&1 \
&& gtk-update-icon-cache -f -t "${XDG_DATA_HOME:-$HOME/.local/share}/icons/hicolor" 2>/dev/null
printf 'Installed %s\n' "$name"
printf ' %s\n' "$entry"
}
cmd_list() {
local entry name url
shopt -s nullglob
for entry in "$APPLICATIONS/$LAUNCH_MARKER-"*.desktop; do
name="$(sed -n 's/^Name=//p' "$entry" | head -1)"
url="$(sed -n 's/^X-Panama-WebApp=//p' "$entry" | head -1)"
printf '%s\t%s\n' "$name" "$url"
done
}
cmd_remove() {
local name="${1:-}"
[[ -n "$name" ]] || { err 'remove needs a name'; return 2; }
local slug; slug="$(slugify "$name")"
local entry="$APPLICATIONS/$LAUNCH_MARKER-$slug.desktop"
# Only ever removes an entry this command installed. The prefix and the
# X-Panama-WebApp key both have to be there, so a name collision with a
# real application cannot delete it.
[[ -f "$entry" ]] || { err "no web app named '$name'"; return 1; }
grep -q '^X-Panama-WebApp=' "$entry" || { err "$entry is not a Panama web app"; return 1; }
rm -f "$entry" "$ICONS/$slug.png"
command -v update-desktop-database >/dev/null 2>&1 \
&& update-desktop-database "$APPLICATIONS" >/dev/null 2>&1
printf 'Removed %s\n' "$name"
}
case "${1:-}" in
install) shift; cmd_install "$@" ;;
list) shift; cmd_list "$@" ;;
remove) shift; cmd_remove "$@" ;;
-h|--help|"")
cat <<'USAGE'
usage: panama-webapp install <url> [name]
panama-webapp list
panama-webapp remove <name>
Turns a website into an application: its own icon, its own window with no
browser chrome, and its own entry in the launcher.
USAGE
;;
*) err "unknown command: $1"; exit 2 ;;
esac
Executable
+589
View File
@@ -0,0 +1,589 @@
#!/usr/bin/env bash
# Panama's front door: the one command a fresh Fedora machine needs.
#
# Download this file from the documented commit URL, verify its documented
# SHA-256, then pass both immutable values as PANAMA_BOOT_REVISION and
# PANAMA_BOOT_SHA256 when invoking it.
#
# Deliberately dumb, because a copy of this script leaves the repository the
# moment somebody curls it -- nothing here can be fixed by re-running
# ./install, so there is as little here as possible: get git, get the clone,
# hand off. Everything with judgment in it lives in `install`, which is also
# where re-runs and upgrades already work.
#
# The one exception to dumb is the root path below, which cannot live in
# `install`: a fresh VPS hands you a root login and nothing else, and the user
# that `install` needs to exist is exactly what has not been created yet.
set -euo pipefail
if [[ ! "${PANAMA_BOOT_REVISION:-}" =~ ^[0-9a-f]{40}$ ]]; then
echo "boot: PANAMA_BOOT_REVISION must be a full lowercase commit" >&2
exit 1
fi
if [[ ! "${PANAMA_BOOT_SHA256:-}" =~ ^[0-9a-f]{64}$ ]]; then
echo "boot: PANAMA_BOOT_SHA256 must be a lowercase SHA-256" >&2
exit 1
fi
actual_boot_sha="$(sha256sum "${BASH_SOURCE[0]}" | cut -d' ' -f1)"
if [[ "$actual_boot_sha" != "$PANAMA_BOOT_SHA256" ]]; then
echo "boot: downloaded boot file does not match PANAMA_BOOT_SHA256" >&2
exit 1
fi
REPO_URL="https://git.gbrown.org/gib/Panama.git"
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
export PANAMA_PATH
BOOTSTRAP_USER=""
checkout_command() {
if [[ -n "$BOOTSTRAP_USER" ]]; then
runuser -u "$BOOTSTRAP_USER" -- "$@"
else
"$@"
fi
}
# Git's index hints are performance promises, not trust evidence. In
# particular, assume-unchanged and skip-worktree can make porcelain status
# report a clean checkout whose files no longer match HEAD. Compare every
# tracked blob and Git mode with the verified commit before handing control to
# any file in the worktree.
checkout_matches_verified_commit() (
local checkout="$1" listing="" entry metadata mode type expected path actual
local link_target_with_sentinel link_target
trap '[[ -z "$listing" ]] || rm -f -- "$listing"' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
listing="$(mktemp -u -t panama-boot-tree.XXXXXX)" || exit 1
umask 077
if ! (set -o noclobber; : >"$listing") 2>/dev/null; then
listing=""
exit 1
fi
checkout_command git -C "$checkout" ls-tree -rz --full-tree \
"$PANAMA_BOOT_REVISION" >"$listing" || exit 1
while IFS= read -r -d '' entry; do
[[ "$entry" == *$'\t'* ]] || exit 1
metadata="${entry%%$'\t'*}"
path="${entry#*$'\t'}"
read -r mode type expected <<<"$metadata"
[[ "$type" == blob && -n "$path" && "$path" != /* ]] || exit 1
case "$mode" in
100644) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& ! -x "$checkout/$path" ]] || exit 1 ;;
100755) [[ -f "$checkout/$path" && ! -L "$checkout/$path" \
&& -x "$checkout/$path" ]] || exit 1 ;;
120000)
[[ -L "$checkout/$path" ]] || exit 1
# hash-object given a pathname follows a symlink. Git's 120000 blob is
# the link text itself, including any trailing newlines, so preserve
# those bytes with a sentinel and hash stdin instead.
link_target_with_sentinel="$(
readlink -n -- "$checkout/$path" && printf .
)" || exit 1
[[ "$link_target_with_sentinel" == *. ]] || exit 1
link_target="${link_target_with_sentinel%.}"
actual="$(
printf '%s' "$link_target" \
| checkout_command git -C "$checkout" hash-object --stdin
)" || exit 1
[[ "$actual" == "$expected" ]] || exit 1
continue
;;
*) exit 1 ;;
esac
actual="$(checkout_command git -C "$checkout" hash-object --no-filters -- "$path")" \
|| exit 1
[[ "$actual" == "$expected" ]] || exit 1
done <"$listing"
)
prepare_panama_checkout() {
local checkout="$1" actual_head checkout_status
if [[ -e "$checkout" && ! -d "$checkout/.git" ]]; then
printf 'boot: %s exists but is not a Panama Git checkout\n' "$checkout" >&2
return 1
fi
if [[ -d "$checkout/.git" ]]; then
checkout_status="$(checkout_command git -C "$checkout" status --porcelain)" || {
printf 'boot: could not inspect the existing checkout at %s\n' "$checkout" >&2
return 1
}
if [[ -n "$checkout_status" ]]; then
printf 'boot: existing checkout at %s has local changes\n' "$checkout" >&2
return 1
fi
echo "Panama is already cloned at $checkout; fetching the verified revision"
checkout_command git -C "$checkout" fetch origin "$PANAMA_BOOT_REVISION" || return 1
if ! checkout_command git -C "$checkout" merge-base --is-ancestor \
HEAD "$PANAMA_BOOT_REVISION"; then
echo "boot: existing checkout cannot fast-forward to the verified revision" >&2
return 1
fi
checkout_command git -C "$checkout" merge --ff-only "$PANAMA_BOOT_REVISION" || return 1
else
checkout_command mkdir -p "$(dirname "$checkout")" || return 1
checkout_command git init "$checkout" || return 1
checkout_command git -C "$checkout" remote add origin "$REPO_URL" || return 1
checkout_command git -C "$checkout" fetch --depth=1 origin \
"$PANAMA_BOOT_REVISION" || return 1
checkout_command git -C "$checkout" checkout --detach \
"$PANAMA_BOOT_REVISION" || return 1
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
echo "boot: fetched checkout does not match PANAMA_BOOT_REVISION" >&2
return 1
fi
checkout_command git -C "$checkout" checkout -b main || return 1
checkout_command git -C "$checkout" config branch.main.remote origin || return 1
checkout_command git -C "$checkout" config branch.main.merge refs/heads/main || return 1
fi
actual_head="$(checkout_command git -C "$checkout" rev-parse 'HEAD^{commit}')" || return 1
if [[ "$actual_head" != "$PANAMA_BOOT_REVISION" ]]; then
echo "boot: checkout HEAD does not match PANAMA_BOOT_REVISION" >&2
return 1
fi
}
SERVER=0
INSTALL_ARGS=()
for arg in "$@"; do
case "$arg" in
--server) SERVER=1; INSTALL_ARGS+=(--server) ;;
*)
printf 'boot: unknown argument: %s\n' "$arg" >&2
printf 'usage: boot [--server]\n' >&2
exit 2 ;;
esac
done
# Keep the worktree comparison at the last possible boundary. Checkout
# preparation may invoke several commands and return to the caller; performing
# the byte/mode/link check here ensures a change in that interval is rejected
# before any tracked file is executed.
verified_install_handoff() {
local use_tty="$1"
if ! checkout_matches_verified_commit "$PANAMA_PATH"; then
echo "boot: checkout files do not match PANAMA_BOOT_REVISION" >&2
return 1
fi
if [[ -n "$BOOTSTRAP_USER" ]]; then
if (( use_tty )); then
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec runuser -u "$BOOTSTRAP_USER" -- env PANAMA_PATH="$PANAMA_PATH" \
"$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
fi
if (( use_tty )); then
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"} </dev/tty
fi
exec "$PANAMA_PATH/install" ${INSTALL_ARGS[@]+"${INSTALL_ARGS[@]}"}
}
# The public bootstrap contract runs this branch as an ordinary user with a
# stubbed root identity. Keep its filesystem adapter unavailable to a real root
# shell so it cannot redirect a real installation by accident.
BOOT_ROOT="${PANAMA_BOOT_FIXTURE_ROOT:-}"
if [[ -n "$BOOT_ROOT" && "$EUID" -eq 0 ]]; then
echo "boot: PANAMA_BOOT_FIXTURE_ROOT is test-only" >&2
exit 1
fi
system_path() {
local path="$1"
[[ "$path" == /* ]] || return 2
printf '%s%s\n' "$BOOT_ROOT" "$path"
}
valid_authorized_keys() {
local keys="$1" line saw_key=0
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]]; then
continue
fi
if ! ssh-keygen -l -f /dev/stdin >/dev/null 2>&1 <<<"$line"; then
return 1
fi
saw_key=1
done <"$keys"
(( saw_key ))
}
safe_authorized_keys() {
local username="$1" user_home="$2" uid ssh_dir keys
uid="$(id -u "$username")" || return 1
[[ "$uid" =~ ^[0-9]+$ && "$uid" != 0 && "$user_home" == /* ]] || return 1
ssh_dir="$user_home/.ssh"
keys="$ssh_dir/authorized_keys"
[[ -d "$ssh_dir" && ! -L "$ssh_dir" && -f "$keys" && ! -L "$keys" ]] || return 1
[[ "$(stat -Lc '%u:%a' "$ssh_dir")" == "$uid:700" ]] || return 1
[[ "$(stat -Lc '%u:%a' "$keys")" == "$uid:600" ]] || return 1
valid_authorized_keys "$keys"
}
safe_root_authorized_keys() {
local keys
keys="$(system_path /root/.ssh/authorized_keys)" || return 1
[[ -f "$keys" && ! -L "$keys" ]] || return 1
[[ "$(stat -Lc '%u:%a' "$keys")" == '0:600' ]] || return 1
valid_authorized_keys "$keys"
}
detect_ssh_unit() {
local unit
for unit in sshd.service ssh.service; do
systemctl cat "$unit" >/dev/null 2>&1 && {
printf '%s\n' "$unit"
return 0
}
done
return 1
}
restore_ssh_dropin() {
local restore
if (( ssh_had_prior )); then
[[ -n "$ssh_backup" && -f "$ssh_backup" && ! -L "$ssh_backup" ]] || return 1
restore="$(mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.restore)" || return 1
if ! cp -a -- "$ssh_backup" "$restore"; then
remove_ssh_artifact "$restore" || true
return 1
fi
if ! mv -f -- "$restore" "$ssh_dropin"; then
remove_ssh_artifact "$restore" || true
return 1
fi
else
remove_ssh_artifact "$ssh_dropin"
fi
}
restore_ssh_transaction_traps() {
trap - EXIT INT TERM
[[ -n "${ssh_saved_exit_trap:-}" ]] && eval "$ssh_saved_exit_trap"
[[ -n "${ssh_saved_int_trap:-}" ]] && eval "$ssh_saved_int_trap"
[[ -n "${ssh_saved_term_trap:-}" ]] && eval "$ssh_saved_term_trap"
return 0
}
remove_ssh_artifact() {
local artifact="$1"
[[ -n "$artifact" && ( -e "$artifact" || -L "$artifact" ) ]] || return 0
if rm -f -- "$artifact"; then
return 0
fi
printf 'SSH transaction cleanup failed. Retained artifact: %s\n' "$artifact" >&2
printf ' rm -f -- %q\n' "$artifact" >&2
return 1
}
print_ssh_recovery() {
if (( ssh_had_prior )); then
printf 'SSH rollback needs manual recovery. Backup: %s\n' "$ssh_backup" >&2
printf ' cp -a -- %q %q\n' "$ssh_backup" "$ssh_dropin" >&2
else
printf 'SSH rollback needs manual recovery. No prior drop-in existed.\n' >&2
printf ' rm -f -- %q\n' "$ssh_dropin" >&2
fi
printf ' sshd -t\n' >&2
printf ' systemctl reload %s\n' "$ssh_unit" >&2
}
policy_is_no() {
local policy="$1" setting="$2"
awk -v setting="$setting" '
$1 == setting { count += 1; if ($2 != "no") bad = 1 }
END { exit count != 1 || bad }
' <<<"$policy"
}
effective_ssh_policy_is_hardened() {
local username="$1" root_policy target_policy context
context='host=localhost,addr=127.0.0.1'
root_policy="$(sshd -T -C "user=root,$context")" || return 1
policy_is_no "$root_policy" permitrootlogin || return 1
policy_is_no "$root_policy" passwordauthentication || return 1
policy_is_no "$root_policy" kbdinteractiveauthentication || return 1
target_policy="$(sshd -T -C "user=$username,$context")" || return 1
policy_is_no "$target_policy" passwordauthentication || return 1
policy_is_no "$target_policy" kbdinteractiveauthentication
}
rollback_ssh_transaction() {
local reload_restored="$1" rollback_failed=0
restore_ssh_dropin || rollback_failed=1
sshd -t || rollback_failed=1
if (( reload_restored )); then
systemctl reload "$ssh_unit" || rollback_failed=1
fi
ssh_transaction_state=""
restore_ssh_transaction_traps
if (( rollback_failed )); then
print_ssh_recovery
else
remove_ssh_artifact "$ssh_backup" || true
fi
return 1
}
handle_ssh_transaction_exit() {
if [[ "$ssh_transaction_state" == preparing \
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
remove_ssh_artifact "$ssh_candidate" || true
remove_ssh_artifact "$ssh_backup" || true
elif [[ "$ssh_transaction_state" == activating || "$ssh_transaction_state" == activated ]]; then
restore_ssh_dropin || true
fi
}
handle_ssh_transaction_signal() {
local signal_status="$1"
trap - INT TERM
if [[ "$ssh_transaction_state" == preparing \
|| ( "$ssh_transaction_state" == activating && -e "$ssh_candidate" ) ]]; then
ssh_transaction_state=""
restore_ssh_transaction_traps
remove_ssh_artifact "$ssh_candidate" || true
remove_ssh_artifact "$ssh_backup" || true
else
rollback_ssh_transaction 1 || true
fi
exit "$signal_status"
}
harden_server_ssh() {
local username="$1" user_home="$2" sshd_dir ssh_dropin harden ssh_unit
local ssh_candidate="" ssh_backup="" ssh_had_prior=0
local ssh_transaction_state=""
local ssh_saved_exit_trap ssh_saved_int_trap ssh_saved_term_trap
sshd_dir="$(system_path /etc/ssh/sshd_config.d)" || return 1
ssh_dropin="$sshd_dir/00-panama.conf"
if [[ -L "$ssh_dropin" || ( -e "$ssh_dropin" && ! -f "$ssh_dropin" ) ]]; then
printf 'SSH hardening unavailable: %s is not a regular file\n' "$ssh_dropin" >&2
return 2
fi
if ! ssh_unit="$(detect_ssh_unit)"; then
echo "SSH hardening unavailable: neither sshd.service nor ssh.service is installed" >&2
return 2
fi
printf 'Harden sshd (disable root, password, and keyboard-interactive authentication)? [Y/n]: '
read -r harden </dev/tty || harden=""
if [[ "$harden" =~ ^[Nn] ]]; then
return 0
fi
ssh_saved_exit_trap="$(trap -p EXIT)"
ssh_saved_int_trap="$(trap -p INT)"
ssh_saved_term_trap="$(trap -p TERM)"
ssh_transaction_state=preparing
trap 'handle_ssh_transaction_exit' EXIT
trap 'handle_ssh_transaction_signal 130' INT
trap 'handle_ssh_transaction_signal 143' TERM
if ! ssh_candidate="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.tmp)"; then
ssh_transaction_state=""
restore_ssh_transaction_traps
return 1
fi
if ! printf 'PermitRootLogin no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n' >"$ssh_candidate"; then
ssh_transaction_state=""
restore_ssh_transaction_traps
remove_ssh_artifact "$ssh_candidate" || true
return 1
fi
if [[ -e "$ssh_dropin" ]]; then
ssh_had_prior=1
if ! ssh_backup="$(umask 077; mktemp --tmpdir="$sshd_dir" .00-panama.XXXXXX.backup)"; then
ssh_transaction_state=""
restore_ssh_transaction_traps
remove_ssh_artifact "$ssh_candidate" || true
return 1
fi
if ! cp -a -- "$ssh_dropin" "$ssh_backup"; then
ssh_transaction_state=""
restore_ssh_transaction_traps
remove_ssh_artifact "$ssh_candidate" || true
remove_ssh_artifact "$ssh_backup" || true
return 1
fi
fi
ssh_transaction_state=activating
if ! mv -f -- "$ssh_candidate" "$ssh_dropin"; then
ssh_transaction_state=""
restore_ssh_transaction_traps
remove_ssh_artifact "$ssh_candidate" || true
remove_ssh_artifact "$ssh_backup" || true
return 1
fi
ssh_candidate=""
ssh_transaction_state=activated
if ! sshd -t || ! effective_ssh_policy_is_hardened "$username"; then
rollback_ssh_transaction 0 || true
return 1
fi
if ! systemctl reload "$ssh_unit"; then
rollback_ssh_transaction 1 || true
return 1
fi
ssh_transaction_state=""
restore_ssh_transaction_traps
remove_ssh_artifact "$ssh_backup" || return 1
echo "Wrote $ssh_dropin; make sure your key works before logging out."
}
# Panama assumes Fedora's repositories and package names.
if ! grep -qi '^ID=fedora' /etc/os-release 2>/dev/null; then
echo "This looks like something other than Fedora; Panama only supports Fedora." >&2
exit 1
fi
# ── Root ─────────────────────────────────────────────────────────────────────
#
# On a desktop, root is a mistake: the clone and every dotfile would land in
# root's home and configure the wrong user. On a fresh VPS it is the starting
# condition -- Hetzner hands over a root login and nothing else -- so with
# --server this walks the machine from that to a normal Panama install: a
# user with sudo, keys, an optionally hardened sshd, and `install --server`
# running as that user. Every step checks before acting, because the machine
# may be anywhere along this path already: a user half-created by hand, keys
# already copied, sshd already locked down.
if [[ "$(id -u)" -eq 0 ]]; then
if (( ! SERVER )); then
echo "Run this as your own user, not root: the install configures YOUR desktop." >&2
echo "Setting up a fresh server from its root login is: boot --server" >&2
exit 1
fi
# Everything here asks, and a root shell from `bash <(curl ...)` can have
# the pipe as stdin, so every prompt reads the terminal explicitly.
if ! (exec </dev/tty) 2>/dev/null; then
echo "No terminal to ask on; run this from an interactive root shell." >&2
exit 1
fi
printf 'Username for this server [gib]: '
read -r username </dev/tty || username=""
username="${username:-gib}"
if id -u "$username" >/dev/null 2>&1; then
echo "User $username already exists"
# wheel is what makes sudo work on Fedora; a user created by hand may not
# have it, and everything after this depends on it.
id -nG "$username" | grep -qw wheel || usermod -aG wheel "$username"
else
echo "Creating $username with sudo (wheel)"
useradd -m -G wheel "$username"
fi
# useradd leaves the account locked, and sudo asks for this password -- a
# user who cannot sudo is a user the install cannot run as.
if ! passwd -S "$username" 2>/dev/null | awk '{exit $2 != "PS" && $2 != "P"}'; then
echo "Set a password for $username (sudo will ask for it):"
passwd "$username" </dev/tty
fi
# Do not close root/password access until the account's key is an exact,
# usable login path. The fixture adapter resolves these logical system paths
# beneath a temporary root; ordinary execution receives the original paths.
logical_user_home="$(getent passwd "$username" | cut -d: -f6)"
user_home=""
if [[ "$logical_user_home" == /* ]]; then
user_home="$(system_path "$logical_user_home")" || true
fi
bootstrap_home="$user_home"
if [[ -z "$bootstrap_home" ]]; then
bootstrap_home="$(system_path "/home/$username")"
fi
user_ssh_dir="$user_home/.ssh"
user_keys="$user_ssh_dir/authorized_keys"
if [[ -n "$user_home" && ! -e "$user_keys" && ! -L "$user_keys" \
&& ! -L "$user_ssh_dir" ]] && safe_root_authorized_keys; then
copy_root_key=0
if [[ ! -e "$user_ssh_dir" ]]; then
copy_root_key=1
elif [[ ! -d "$user_ssh_dir" \
|| "$(stat -Lc '%u:%a' "$user_ssh_dir")" != "$(id -u "$username"):700" ]]; then
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
else
copy_root_key=1
fi
if (( copy_root_key )); then
echo "Copying root's authorized_keys to $username"
root_keys="$(system_path /root/.ssh/authorized_keys)"
if ! runuser -u "$username" -- install -d -m 0700 -- "$user_ssh_dir" \
|| ! runuser -u "$username" -- install -m 0600 -- /dev/stdin "$user_keys" \
<"$root_keys"; then
echo "SSH hardening unavailable: could not install root's key for $username" >&2
fi
fi
fi
if safe_authorized_keys "$username" "$user_home"; then
harden_status=0
harden_server_ssh "$username" "$user_home" || harden_status=$?
if (( harden_status != 0 && harden_status != 2 )); then
echo "SSH hardening failed; stopping before install handoff." >&2
exit 1
fi
else
echo "SSH hardening unavailable: $username has no safe authorized_keys" >&2
fi
if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs"
dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi
# Create or advance the checkout as the target user. A root-owned .git in a
# user's home would break every later update.
PANAMA_PATH="$bootstrap_home/.local/share/Panama"
BOOTSTRAP_USER="$username"
prepare_panama_checkout "$PANAMA_PATH"
echo "Handing off to install as $username"
verified_install_handoff 1
fi
# git is the one dependency the clone itself needs. Everything else -- gum
# included -- is bootstrapped by `install`.
if ! command -v git >/dev/null 2>&1; then
echo "Installing git, which the clone needs"
sudo dnf install -y --repo=fedora --repo=updates --from-repo=fedora,updates git
fi
prepare_panama_checkout "$PANAMA_PATH"
# A shell invoked from automation can have a pipe as stdin, while the first
# thing install runs is the interview, which has to be able to ask.
# Reattach the terminal when there is one; without one the interview will say
# so itself.
# The probe actually opens /dev/tty rather than testing -r: a process with no
# controlling terminal passes -r and then fails the redirect.
handoff_tty=0
if [[ ! -t 0 ]] && (exec </dev/tty) 2>/dev/null; then
handoff_tty=1
fi
verified_install_handoff "$handoff_tty"
+5 -10
View File
@@ -5,11 +5,11 @@ export PANAMA_BASH="$PANAMA_PATH/config/bash"
[ -f /etc/bashrc ] && . /etc/bashrc [ -f /etc/bashrc ] && . /etc/bashrc
if [ -d ~/.bashrc.d ]; then if [ -d ~/.bashrc.d ]; then
for rc in ~/.bashrc.d/*; do for rc in ~/.bashrc.d/*; do
if [ -f "$rc" ]; then if [ -f "$rc" ]; then
. "$rc" . "$rc"
fi fi
done done
unset rc unset rc
fi fi
@@ -24,8 +24,3 @@ else
fi fi
export PATH export PATH
fi fi
. "$HOME/.cargo/env"
# >>> Codex installer >>>
export PATH="/home/gib/.local/bin:$PATH"
# <<< Codex installer <<<
+12 -3
View File
@@ -3,11 +3,19 @@
# Aliases I like # Aliases I like
alias :q="exit" alias :q="exit"
alias :wq="exit" alias :wq="exit"
alias startsunshine="systemctl --user restart sunshine.service"
alias sourcerc="source ~/.bashrc" alias sourcerc="source ~/.bashrc"
alias c="clear" alias c="clear"
alias shutdown="systemctl poweroff" alias shutdown="systemctl poweroff"
alias update-grub="sudo grub-mkconfig -o /etc/grub2-efi.cfg" # The config target differs by firmware: EFI machines regenerate the EFI
# config, BIOS machines /boot/grub2/grub.cfg -- writing the EFI path on a BIOS
# machine updates a file nothing boots from.
update-grub() {
if [ -d /sys/firmware/efi ]; then
sudo grub2-mkconfig -o /etc/grub2-efi.cfg
else
sudo grub2-mkconfig -o /boot/grub2/grub.cfg
fi
}
alias nvidia-smi-docker='sudo docker run --rm --gpus all --privileged nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi' alias nvidia-smi-docker='sudo docker run --rm --gpus all --privileged nvidia/cuda:12.8.1-base-ubuntu24.04 nvidia-smi'
alias ncconnect='sudo docker exec -u www-data -it nextcloud-aio-nextcloud bash' alias ncconnect='sudo docker exec -u www-data -it nextcloud-aio-nextcloud bash'
alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mode()end, 100)"' alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mode()end, 100)"'
@@ -32,7 +40,8 @@ alias ls='eza -lh --group-directories-first --icons'
alias lsa='ls -a' alias lsa='ls -a'
alias lt='eza --tree --level=2 --long --icons --git' alias lt='eza --tree --level=2 --long --icons --git'
alias lta='lt -a' alias lta='lt -a'
alias ff="fzf --preview 'batcat --style=numbers --color=always {}'" # Fedora's bat installs /usr/bin/bat; batcat is the Debian name.
alias ff="fzf --preview 'bat --style=numbers --color=always {}'"
# Directories # Directories
alias ..='cd ..' alias ..='cd ..'
+41 -6
View File
@@ -1,5 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Personal environment -- API keys, tokens -- lives OUTSIDE the checkout,
# where agents, backup tools and `panama update` walk, and is kept owner-only
# every time it is read: a secrets file that drifts to 644 is quietly
# re-tightened rather than trusted. Sourced first, because settings below
# (PANAMA_SSH_TMUX) read it. (config/bash/env, its old home inside the repo,
# is still sourced by .bashrc's glob if a machine has not been migrated yet.)
PANAMA_ENV="${XDG_CONFIG_HOME:-$HOME/.config}/panama/env"
if [ -f "$PANAMA_ENV" ]; then
[ "$(stat -c %a "$PANAMA_ENV" 2>/dev/null)" = "600" ] || chmod 600 "$PANAMA_ENV"
. "$PANAMA_ENV"
fi
unset PANAMA_ENV
# Editor used by CLI # Editor used by CLI
export EDITOR="nvim" export EDITOR="nvim"
export SUDO_EDITOR="$EDITOR" export SUDO_EDITOR="$EDITOR"
@@ -22,18 +35,33 @@ export DOTNETPATH="$HOME/.dotnet/tools"
# Set complete path # Set complete path
export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH" export PATH="$HOME/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PANAMA_PATH/bin:$BUN_INSTALL/bin:$CARGO_PATH/bin:$PNPM_HOME/bin:$PYENV_ROOT/bin:$HOME/.rbenv/bin:/usr/lib/ccache/bin/:$GOPATH/bin:$DOTNETPATH"
# Nvm # rustup writes this file, and initial-packages installs rustup rather than
source /etc/profile.d/nvm.sh # running rustup-init -- so on a fresh machine it does not exist yet and an
# unguarded source made every single shell start with an error.
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
# Nvm. Guarded because the file belongs to the nvm package: before that is
# installed it does not exist, and an unconditional source means every shell on
# a fresh machine opens with an error.
[ -f /etc/profile.d/nvm.sh ] && source /etc/profile.d/nvm.sh
# Auto-switch Node version when entering a directory with .nvmrc # Auto-switch Node version when entering a directory with .nvmrc
_nvm_auto_use() { _nvm_auto_use() {
if [[ -f .nvmrc ]]; then # Guarded on nvm actually being loaded: without this, a machine where the
# nvm profile script is absent printed "command not found" on every single
# prompt in any directory carrying a .nvmrc.
if [[ -f .nvmrc ]] && type -t nvm >/dev/null 2>&1; then
nvm use --silent nvm use --silent
fi fi
} }
export PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }_nvm_auto_use" export PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND; }_nvm_auto_use"
# Auto-start or attach tmux for SSH interactive shells # Auto-start or attach tmux for SSH interactive shells. A deliberate Panama
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* ]]; then # behavior (tmux is in initial-packages, and a dropped SSH session keeping its
# work is the point), but guarded: it must not replace the shell of someone
# whose machine lacks tmux, and PANAMA_SSH_TMUX=off turns it off for people
# who want a plain shell -- set it in ~/.config/panama/env.
if [[ -n "$SSH_CONNECTION" && -z "$TMUX" && $- == *i* &&
"${PANAMA_SSH_TMUX:-on}" != "off" ]] && command -v tmux >/dev/null 2>&1; then
exec tmux new-session -A -s main exec tmux new-session -A -s main
fi fi
@@ -41,4 +69,11 @@ fi
eval "$(zoxide init bash)" eval "$(zoxide init bash)"
# Oh My Posh # Oh My Posh
eval "$(oh-my-posh init bash --config $PANAMA_PATH/config/dot/ohmyposh/gib.omp.json)" # Guarded for the same reason the nvm source above is: oh-my-posh is a package,
# and a shell opened before it is installed -- a stage re-run by hand, an
# install that failed partway -- would otherwise print command-not-found on
# every prompt. An unthemed prompt is a worse shell; an erroring one is a
# broken-looking machine.
if command -v oh-my-posh >/dev/null 2>&1; then
eval "$(oh-my-posh init bash --config "$PANAMA_PATH/config/dot/ohmyposh/gib.omp.json")"
fi
@@ -0,0 +1,53 @@
# Dictation's speech-to-text server, as a Quadlet.
#
# Quadlet rather than a hand-written unit wrapping `podman run`: systemd
# generates the unit from this at boot, so there is one description of the
# container rather than a unit and a command line drifting apart.
#
# Upstream's own Vulkan image, not one built here. whisper.cpp publishes it, it
# already contains whisper-server, and it is maintained by the people who write
# the thing -- a Containerfile in this repository would be a compile step on
# every machine and a build to keep working, in exchange for nothing.
#
# Vulkan rather than ROCm, which is the reason this tag and not another. ROCm's
# runtime is seven gigabytes and serves AMD alone; Vulkan compute runs on AMD,
# Intel and NVIDIA through whatever Mesa driver a machine already has. These
# machines are a mix of all three, and this image works on every one of them.
#
# Deliberately no [Install] section. whisper-server loads the model when it
# starts and holds it, so a container started at login costs half a gigabyte of
# memory in every session where nobody dictates. panama-dictate starts it on the
# first press of the key, and it stays up for the rest of the session.
[Unit]
Description=Panama dictation speech-to-text server
Documentation=https://github.com/ggml-org/whisper.cpp
[Container]
Image=ghcr.io/ggml-org/whisper.cpp:main-vulkan
# The image's own entrypoint is a shell; the server is what is wanted.
Entrypoint=/app/build/bin/whisper-server
Exec=--host 0.0.0.0 --port 8791 --model /models/ggml-small.bin --inference-path /inference
# The whole directory rather than a renderD node: the number differs between
# machines, and this file is meant to be identical on all of them.
AddDevice=/dev/dri
# The model is host state -- fetched once, kept across image updates, and shared
# with nothing else. Read-only because the server never writes to it, and :z
# relabels for SELinux, which is enforcing on Fedora.
Volume=%h/.local/share/panama/whisper:/models:ro,z
# Loopback only. This transcribes whatever it is sent, with no authentication,
# and has no business being reachable from the network.
PublishPort=127.0.0.1:8791:8791
NoNewPrivileges=true
[Service]
# Loading the model takes a few seconds on a cold start; systemd should wait for
# it rather than give up, and should bring the server back if it dies mid-session.
TimeoutStartSec=180
Restart=on-failure
RestartSec=3
-8
View File
@@ -1,8 +0,0 @@
# see `man dnf.conf` for defaults and possible options
[main]
fastestmirror=True
max_parallel_downloads=10
defaultyes=True
keepcache=True
deltarpm=True
@@ -0,0 +1,8 @@
# A stray press of the power button should ask, not act. Fedora's logind
# default is HandlePowerKey=poweroff -- an instant, unconfirmed shutdown.
# Panama binds the key in the compositor to open its power menu instead
# (keybinds.lua), the way GNOME turns the key into a dialog, so logind is
# told to stand down. Holding the button still hard-cuts through firmware,
# which remains the escape hatch this file cannot and should not remove.
[Login]
HandlePowerKey=ignore
@@ -0,0 +1,35 @@
# Clear the mountpoint before starting, so a start that follows a crash lands
# on clean ground.
#
# This service provides the fuse mount at /run/user/$UID/doc, and bwrap binds
# doc/by-app/<id> into every flatpak sandbox it builds. No mount, no sandbox,
# no application -- all of them, not one, because they all share this.
#
# The shipped unit is Type=dbus with Restart=no, so nothing restarts it on
# failure: it comes back only when something next calls its bus name. On this
# machine that was a flatpak launching two seconds after the daemon exited
# 21, and the activation raced the dying instance's fuse state and came up
# with no mount at all. systemctl then reports active (running) -- the
# fusermount3 helper is even still there as a child -- while every new sandbox
# fails. Nothing running at the time notices, because a sandbox needs the
# mount only while it is being built, so the breakage surfaces whenever you
# next open something you had not opened yet. Here that was three days later,
# and it was the second time: the first was written off as a one-off.
#
# `-` because a clean start has nothing to unmount and fusermount3 exits 1
# saying so, which is not a failure to start on.
#
# The unmount alone did not close the hole: the third recurrence (2026-08-26)
# started on ground the ExecStartPre had cleaned and still came up with no
# mount, sitting active-and-broken for fourteen hours until the next fresh
# flatpak launch found it. Hence ExecStartPost: the unit is not "started"
# until the mount it exists to provide actually exists. Type=dbus declares
# readiness at bus-name acquisition, which can land before the fuse thread
# has the mount up, so this polls rather than checks once. If five seconds
# pass without a mount, the start fails -- loudly, in the journal, as failed
# -- and the next bus activation retries from the ExecStartPre cleanup
# instead of every sandbox build failing against a unit that claims to be
# fine.
[Service]
ExecStartPre=-/usr/bin/fusermount3 -u /run/user/%U/doc
ExecStartPost=/usr/bin/timeout 5 /bin/sh -c 'until /usr/bin/mountpoint -q /run/user/%U/doc; do /usr/bin/sleep 0.2; done'
@@ -0,0 +1,9 @@
# External monitor brightness (DDC/CI) on GPUs that are not "VGA".
#
# ddcutil ships 60-ddcutil-i2c.rules, which grants the seated user access to
# the GPU's i2c buses -- but only when the GPU's PCI class is 0x030000 (VGA
# compatible controller). An AMD iGPU that is not the primary boot display
# enumerates as 0x038000 (Display controller) instead, so every DDC bus it
# exposes stays root-only and the Brightness service reads EACCES. Same grant,
# broadened to the class that hardware actually reports.
SUBSYSTEM=="i2c-dev", KERNEL=="i2c-[0-9]*", ATTRS{class}=="0x038000", TAG+="uaccess"
@@ -1,614 +0,0 @@
"""nautilus extension: nautilus_open_any_terminal"""
# based on: https://github.com/gnunn1/tilix/blob/master/data/nautilus/open-tilix.py
import ast
import re
import shlex
from dataclasses import dataclass, field
from functools import cache
from gettext import gettext, translation
from os.path import expanduser
from subprocess import Popen
from typing import Optional
from urllib.parse import quote, unquote, urlparse
from gi import require_version
try:
require_version("Nautilus", "4.1")
except ValueError:
require_version("Nautilus", "4.0")
require_version("Gtk", "4.0")
from gi.repository import Nautilus as FileManager
API_VERSION = "4.1"
from gi.repository import Gio, GLib, GObject, Gtk # noqa: E402 pylint: disable=wrong-import-position
@dataclass(frozen=True)
class Terminal:
"""Data class representing a terminal configuration."""
name: str
workdir_arguments: Optional[list[str]] = None
new_tab_arguments: Optional[list[str]] = None
new_window_arguments: Optional[list[str]] = None
command_arguments: list[str] = field(default_factory=lambda: ["-e"])
flatpak_package: Optional[str] = None
_ = gettext
for localedir in [expanduser("~/.local/share/locale"), "/usr/share/locale"]:
try:
trans = translation("nautilus-open-any-terminal", localedir)
trans.install()
_ = trans.gettext
break
except FileNotFoundError:
continue
TERMINALS = {
"alacritty": Terminal("Alacritty"),
"app2unit-term": Terminal("app2unit-term"),
"blackbox": Terminal(
"Black Box",
workdir_arguments=["--working-directory"],
command_arguments=["-c"],
flatpak_package="com.raggesilver.BlackBox",
),
"blackbox-terminal": Terminal(
"Black Box",
workdir_arguments=["--working-directory"],
command_arguments=["-c"],
),
"bobcat": Terminal(
"Bobcat",
workdir_arguments=["--working-dir"],
command_arguments=["--"],
),
"cool-retro-term": Terminal("cool-retro-term", workdir_arguments=["--workdir"]),
"custom": Terminal(_("Terminal"), command_arguments=[]),
"contour": Terminal(
"Contour",
workdir_arguments=["--working-directory"],
flatpak_package="org.contourterminal.Contour",
),
"cosmic-term": Terminal("COSMIC Terminal"),
"deepin-terminal": Terminal("Deepin Terminal"),
"ddterm": Terminal(
"Drop down Terminal extension",
workdir_arguments=["--working-directory"],
flatpak_package="com.github.amezin.ddterm",
),
"foot": Terminal("Foot"),
"footclient": Terminal("FootClient"),
"ghostty": Terminal("Ghostty"),
"gnome-terminal": Terminal("Terminal", new_tab_arguments=["--tab"], command_arguments=["--"]),
"guake": Terminal("Guake", workdir_arguments=["--show", "--new-tab"]),
"kermit": Terminal("Kermit"),
"kgx": Terminal("Console", new_tab_arguments=["--tab"]),
"kitty": Terminal("Kitty"),
"konsole": Terminal("Konsole", new_tab_arguments=["--new-tab"]),
"mate-terminal": Terminal("Mate Terminal", new_tab_arguments=["--tab"]),
"mlterm": Terminal("Mlterm"),
"ptyxis": Terminal(
"Ptyxis",
workdir_arguments=["-d"],
command_arguments=["--"],
new_tab_arguments=["--tab"],
new_window_arguments=["--new-window"],
flatpak_package="app.devsuite.Ptyxis",
),
"ptyxis-nightly": Terminal(
"Ptyxis",
workdir_arguments=["-d"],
command_arguments=["--"],
new_tab_arguments=["--tab"],
new_window_arguments=["--new-window"],
flatpak_package="org.gnome.Ptyxis.Devel",
),
"qterminal": Terminal("QTerminal"),
"rio": Terminal("Rio"),
"sakura": Terminal("Sakura"),
"st": Terminal("Simple Terminal"),
"tabby": Terminal("Tabby", command_arguments=["run"], workdir_arguments=["open"]),
"terminator": Terminal("Terminator", new_tab_arguments=["--new-tab"]),
"terminology": Terminal("Terminology"),
"terminus": Terminal("Terminus"),
"termite": Terminal("Termite"),
"tilix": Terminal("Tilix", flatpak_package="com.gexperts.Tilix"),
"urxvt": Terminal("rxvt-unicode"),
"urxvtc": Terminal("urxvtc"),
"uwsm-terminal": Terminal("uwsm-terminal"),
"uxterm": Terminal("UXTerm"),
"warp": Terminal(
"Warp",
new_tab_arguments=["--virtual-arg-for-tabs"], # This is just to indicate tab support
),
"wezterm": Terminal(
"Wez's Terminal Emulator",
workdir_arguments=["--cwd"],
new_tab_arguments=["start", "--new-tab"],
new_window_arguments=["start"],
flatpak_package="org.wezfurlong.wezterm",
),
"xfce4-terminal": Terminal("Xfce Terminal", new_tab_arguments=["--tab"]),
"xterm": Terminal("XTerm"),
}
FLATPAK_PARMS = ["off", "system", "user"]
terminal = "gnome-terminal"
terminal_cmd: list[str] = None # type: ignore
terminal_data: Terminal = TERMINALS["gnome-terminal"]
new_tab = False
flatpak = FLATPAK_PARMS[0]
custom_local_command: str
custom_remote_command: str
GSETTINGS_PATH = "com.github.stunkymonkey.nautilus-open-any-terminal"
GSETTINGS_KEYBINDINGS = "keybindings"
GSETTINGS_BIND_REMOTE = "bind-remote"
GSETTINGS_TERMINAL = "terminal"
GSETTINGS_NEW_TAB = "new-tab"
GSETTINGS_FLATPAK = "flatpak"
GSETTINGS_USE_GENERIC_TERMINAL_NAME = "use-generic-terminal-name"
GSETTINGS_CUSTOM_LOCAL_COMMAND = "custom-local-command"
GSETTINGS_CUSTOM_REMOTE_COMMAND = "custom-remote-command"
REMOTE_URI_SCHEME = ["ftp", "sftp"]
# Adapted from https://www.freedesktop.org/software/systemd/man/latest/os-release.html
def read_os_release():
"""Read and parse the OS release information."""
possible_os_release_paths = ["/etc/os-release", "/usr/lib/os-release"]
for file_path in possible_os_release_paths:
try:
with open(file_path, mode="r", encoding="utf-8") as os_release:
for line_number, line in enumerate(os_release, start=1):
line = line.rstrip()
if not line or line.startswith("#"):
continue
result = re.match(r"([A-Z][A-Z_0-9]+)=(.*)", line)
if result:
name, val = result.groups()
if val and val[0] in "\"'":
val = ast.literal_eval(val)
yield name, val
else:
raise OSError(f"{file_path}:{line_number}: bad line {line!r}")
except FileNotFoundError:
continue
@cache
def distro_id() -> set[str]:
"""get the set of distribution ids"""
try:
os_release = dict(read_os_release())
except OSError:
return set(["unknown"])
ids = [os_release["ID"]]
if id_like := os_release.get("ID_LIKE"):
ids.extend(id_like.split(" "))
return set(ids)
def parse_custom_command(command: str, data: str | list[str]) -> list[str]:
"""Substitute every '%s' in the command with data and split it into arguments"""
if isinstance(data, str):
data = [data]
return shlex.split(command.replace("%s", shlex.join(data)))
def run_command_in_terminal(command: list[str], *, cwd: str | None = None):
if terminal == "custom":
cmd = parse_custom_command(custom_remote_command, command)
else:
cmd = terminal_cmd.copy()
if cwd and terminal_data.workdir_arguments:
cmd.extend(terminal_data.workdir_arguments)
cmd.append(cwd)
cmd.extend(terminal_data.command_arguments)
cmd.extend(command)
Popen(cmd, cwd=cwd) # pylint: disable=consider-using-with
def ssh_command_from_uri(uri: str, *, is_directory: bool):
"""Creates an ssh command that executes or cd's into remote uri"""
result = urlparse(uri)
cmd = ["ssh", "-t"]
if result.username:
cmd.append(f"{result.username}@{result.hostname}")
else:
cmd.append(result.hostname) # type: ignore
if result.port:
cmd.append("-p")
cmd.append(str(result.port))
target = shlex.quote(unquote(result.path))
if is_directory:
cmd.extend(["cd", target, ";", "exec", "${SHELL:-/bin/sh}", "-l"])
else:
cmd.extend(["exec", target])
return cmd
def open_remote_terminal_in_uri(uri: str):
"""Open a new remote terminal"""
run_command_in_terminal(ssh_command_from_uri(uri, is_directory=True))
def open_local_terminal_in_uri(uri: str):
"""open the new terminal with correct path"""
result = urlparse(uri)
filename = unquote(result.path)
if result.scheme == "admin":
run_command_in_terminal(["sudo", "-s"], cwd=filename)
return
if terminal == "warp":
# Force new_tab to be considered even without traditional tab arguments
Popen( # pylint: disable=consider-using-with
["xdg-open", f"warp://action/new_{'tab' if new_tab else 'window'}?path={result.path}"]
)
return
cmd = terminal_cmd.copy()
if terminal == "custom":
cmd = parse_custom_command(custom_local_command, filename)
elif filename and terminal_data.workdir_arguments:
cmd.extend(terminal_data.workdir_arguments)
cmd.append(filename)
Popen(cmd, cwd=filename) # pylint: disable=consider-using-with
def directory_menu_item_id(*, foreground: bool, remote: bool):
return f"OpenTerminal::open{'_' if foreground else '_bg_'}{'remote' if remote else 'file'}_item"
def executable_menu_item_id(*, remote: bool):
return f"OpenTerminal::execute{'_remote_' if remote else '_file_'}item"
def get_directory_menu_items(
file: FileManager.FileInfo, callback, *, foreground: bool, terminal_name: str | None = None
):
items = []
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
terminal_name = terminal_name or terminal_data.name
if remote:
if foreground:
REMOTE_LABEL = _("Open in Remote {}")
REMOTE_TIP = _("Open Remote {} in {}")
LOCAL_LABEL = _("Open in Local {}")
LOCAL_TIP = _("Open Local {} in {}")
tip = REMOTE_TIP.format(terminal_name, file.get_name())
else:
REMOTE_LABEL = _("Open Remote {} Here")
REMOTE_TIP = _("Open Remote {} in This Directory")
LOCAL_LABEL = _("Open Local {} Here")
LOCAL_TIP = _("Open Local {} in This Directory")
tip = REMOTE_TIP.format(terminal_name)
item = FileManager.MenuItem(
name=directory_menu_item_id(foreground=foreground, remote=True),
label=REMOTE_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, True)
items.append(item)
elif foreground:
LOCAL_LABEL = _("Open in {}")
LOCAL_TIP = _("Open {} in {}")
else:
LOCAL_LABEL = _("Open {} Here")
LOCAL_TIP = _("Open {} in This Directory")
# Let wezterm handle opening a local terminal
if terminal == "wezterm" and flatpak == "off":
return items
if foreground:
tip = LOCAL_TIP.format(terminal_name, file.get_name())
else:
tip = LOCAL_TIP.format(terminal_name)
item = FileManager.MenuItem(
name=directory_menu_item_id(foreground=foreground, remote=False),
label=LOCAL_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, False)
items.append(item)
return items
def get_executable_menu_items(file: FileManager.FileInfo, callback, *, terminal_name: str | None = None):
items = []
remote = file.get_uri_scheme() in REMOTE_URI_SCHEME
terminal_name = terminal_name or terminal_data.name
if remote:
REMOTE_LABEL = _("Execute in Remote {}")
REMOTE_TIP = _("Execute {} in {} via SSH")
LOCAL_LABEL = _("Execute in Local {}")
LOCAL_TIP = _("Execute {} in Local {}")
tip = REMOTE_TIP.format(file.get_name(), terminal_name)
item = FileManager.MenuItem(
name=executable_menu_item_id(remote=True),
label=REMOTE_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, True)
items.append(item)
else:
LOCAL_LABEL = _("Execute in {}")
LOCAL_TIP = _("Execute {} in {}")
tip = LOCAL_TIP.format(file.get_name(), terminal_name)
item = FileManager.MenuItem(
name=executable_menu_item_id(remote=False),
label=LOCAL_LABEL.format(terminal_name),
tip=tip,
)
item.connect("activate", callback, file, False)
items.append(item)
return items
def is_executable(file: Gio.File) -> bool:
try:
attributes = file.query_info("access::can-execute", Gio.FileQueryInfoFlags.NONE)
except GLib.Error:
return False
return attributes.get_attribute_boolean("access::can-execute")
def set_terminal_args(*_args):
# pylint: disable=possibly-used-before-assignment
"""set the terminal_cmd to the correct values"""
global new_tab
global flatpak
global terminal_cmd
global terminal_data
global custom_local_command
global custom_remote_command
value = _gsettings.get_string(GSETTINGS_TERMINAL)
newer_tab = _gsettings.get_boolean(GSETTINGS_NEW_TAB)
flatpak = FLATPAK_PARMS[_gsettings.get_enum(GSETTINGS_FLATPAK)]
new_terminal_data = TERMINALS.get(value)
if not new_terminal_data:
print(f'open-any-terminal: unknown terminal "{value}"')
return
global terminal
terminal = value
terminal_data = new_terminal_data
if newer_tab and terminal_data.new_tab_arguments:
new_tab = newer_tab
new_tab_text = "opening in a new tab"
else:
new_tab_text = "opening a new window"
if newer_tab and not terminal_data.new_tab_arguments:
new_tab_text += " (terminal does not support tabs)"
if flatpak != FLATPAK_PARMS[0] and terminal_data.flatpak_package is not None:
terminal_cmd = ["flatpak", "run", "--" + flatpak, terminal_data.flatpak_package]
flatpak_text = f"with flatpak as {flatpak}"
else:
terminal_cmd = [terminal]
if terminal == "blackbox" and "fedora" in distro_id():
# It's called like this on fedora
terminal_cmd[0] = "blackbox-terminal"
flatpak = FLATPAK_PARMS[0]
flatpak_text = ""
if terminal == "custom":
terminal_cmd = []
custom_local_command = _gsettings.get_string(GSETTINGS_CUSTOM_LOCAL_COMMAND)
custom_remote_command = _gsettings.get_string(GSETTINGS_CUSTOM_REMOTE_COMMAND)
elif new_tab and terminal_data.new_tab_arguments:
terminal_cmd.extend(terminal_data.new_tab_arguments)
elif terminal_data.new_window_arguments:
terminal_cmd.extend(terminal_data.new_window_arguments)
print(f'open-any-terminal: terminal is set to "{terminal}" {new_tab_text} {flatpak_text}')
if API_VERSION == ("4.0", "4.1"):
class OpenAnyTerminalShortcutProvider(GObject.GObject, FileManager.MenuProvider):
"""Provide keyboard shortcuts for opening terminals in Nautilus."""
def __init__(self):
super().__init__()
self.previous_cwd = expanduser("~")
gsettings_source = Gio.SettingsSchemaSource.get_default()
if gsettings_source.lookup(GSETTINGS_PATH, True):
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
self._setup_keybindings()
def get_background_items(self, current_folder: FileManager.FileInfo):
"""Update current URI when folder changes."""
if current_folder:
if current_folder.get_uri_scheme() in REMOTE_URI_SCHEME:
folder_path = current_folder.get_uri()
else:
folder_path = current_folder.get_location().get_path()
if folder_path and folder_path != self.previous_cwd:
self.previous_cwd = folder_path
return []
def _open_terminal(self, *_args):
"""Open the terminal at the specified URI."""
if self._gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
open_remote_terminal_in_uri(self.previous_cwd)
else:
open_local_terminal_in_uri(self.previous_cwd)
def _setup_keybindings(self):
"""Set up custom keybindings for the extension."""
self.app = Gtk.Application.get_default()
if self.app is None:
print("No Gtk.Application found. Keybindings cannot be set.")
return
action = Gio.SimpleAction.new("open_any_terminal", None)
action.connect("activate", self._open_terminal)
self.app.add_action(action)
self._bind_shortcut()
self._gsettings.connect("changed", self._update_shortcut)
def _update_shortcut(self, _gsettings, key):
"""remove keybinding"""
if key == GSETTINGS_KEYBINDINGS:
self.app.set_accels_for_action("app.open_any_terminal", [])
self._bind_shortcut()
def _bind_shortcut(self):
"""Parse and update keybindings when settings change."""
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
if not shortcut:
self.app.set_accels_for_action("app.open_any_terminal", [])
return
valid, key, mods = Gtk.accelerator_parse(shortcut)
if not valid:
print("Invalid shortcut in GSettings: %r", shortcut)
self.app.set_accels_for_action("app.open_any_terminal", [])
return
normalized = Gtk.accelerator_name(key, mods)
self.app.set_accels_for_action("app.open_any_terminal", [normalized])
elif API_VERSION in ("3.0", "2.0"):
class OpenAnyTerminalShortcutProviderLegacy(GObject.GObject, FileManager.LocationWidgetProvider):
"""Provide keyboard shortcuts for opening terminals in Nautilus/Caja."""
def __init__(self):
super().__init__()
gsettings_source = Gio.SettingsSchemaSource.get_default()
if gsettings_source.lookup(GSETTINGS_PATH, True):
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
self._gsettings.connect("changed", self._bind_shortcut)
self._create_accel_group()
self._window = None
self._uri = None
def _create_accel_group(self):
self._accel_group = Gtk.AccelGroup()
shortcut = self._gsettings.get_string(GSETTINGS_KEYBINDINGS)
key, mod = Gtk.accelerator_parse(shortcut)
self._accel_group.connect(key, mod, Gtk.AccelFlags.VISIBLE, self._open_terminal)
def _bind_shortcut(self, _gsettings, key):
if key == GSETTINGS_KEYBINDINGS:
self._accel_group.disconnect(self._open_terminal)
self._create_accel_group()
def _open_terminal(self, *_args):
if _gsettings.get_boolean(GSETTINGS_BIND_REMOTE):
open_local_terminal_in_uri(self._uri)
else:
open_remote_terminal_in_uri(self._uri)
def get_widget(self, uri, window):
"""follows uri and sets the correct window"""
self._uri = uri
if self._window:
self._window.remove_accel_group(self._accel_group)
if self._gsettings:
window.add_accel_group(self._accel_group)
self._window = window
class OpenAnyTerminalExtension(GObject.GObject, FileManager.MenuProvider):
"""Provide context menu items for opening terminals in Nautilus."""
def __init__(self):
super().__init__()
gsettings_source = Gio.SettingsSchemaSource.get_default()
if gsettings_source.lookup(GSETTINGS_PATH, True):
self._gsettings = Gio.Settings.new(GSETTINGS_PATH)
def _get_terminal_name(self):
if self._gsettings.get_boolean(GSETTINGS_USE_GENERIC_TERMINAL_NAME):
return _("Terminal")
return None
def _menu_dir_activate_cb(self, menu, file_, remote: bool):
if remote:
open_remote_terminal_in_uri(file_.get_uri())
else:
if file_.get_uri_scheme() == "smb":
file_uri = "file://" + quote(file_.get_location().get_path())
else:
file_uri = file_.get_uri()
open_local_terminal_in_uri(file_uri)
def _menu_exe_activate_cb(self, menu, file_, remote: bool):
if remote:
cmd = ssh_command_from_uri(file_.get_uri(), is_directory=False)
else:
result = urlparse(file_.get_uri())
file = unquote(result.path)
if result.scheme == "admin":
cmd = ["sudo", file]
elif terminal in ["xterm", "uxterm"]:
cmd = [f"exec {shlex.quote(file)}"]
else:
cmd = [file]
run_command_in_terminal(cmd)
def get_file_items(self, *args):
"""Generates a list of menu items for a file or folder in the Nautilus file manager."""
# `args` will be `[files: List[Nautilus.FileInfo]]` in Nautilus 4.0 API,
# and `[window: Gtk.Widget, files: List[Nautilus.FileInfo]]` in Nautilus 3.0 API.
files = args[-1]
if len(files) != 1:
return []
file_ = files[0]
if file_.is_directory():
return get_directory_menu_items(
file_, self._menu_dir_activate_cb, foreground=True, terminal_name=self._get_terminal_name()
)
if is_executable(file_.get_location()):
return get_executable_menu_items(file_, self._menu_exe_activate_cb, terminal_name=self._get_terminal_name())
return []
def get_background_items(self, *args):
"""Generates a list of background menu items for a file or folder in the Nautilus file manager."""
# `args` will be `[folder: Nautilus.FileInfo]` in Nautilus 4.0 API,
# and `[window: Gtk.Widget, file: Nautilus.FileInfo]` in Nautilus 3.0 API.
file_ = args[-1]
return get_directory_menu_items(
file_, self._menu_dir_activate_cb, foreground=False, terminal_name=self._get_terminal_name()
)
source = Gio.SettingsSchemaSource.get_default()
if source is not None and source.lookup(GSETTINGS_PATH, True):
_gsettings = Gio.Settings.new(GSETTINGS_PATH)
_gsettings.connect("changed", set_terminal_args)
set_terminal_args()
+1 -1
View File
@@ -2,7 +2,7 @@
# #
# btop ships no Tokyo Night light variant at all, and the nearest stock light # btop ships no Tokyo Night light variant at all, and the nearest stock light
# theme (flat-remix-light) is a different palette that happens to have a similar # theme (flat-remix-light) is a different palette that happens to have a similar
# background. Same colours as kitty/themes/tokyonight-day.conf so the terminal # background. Same colors as kitty/themes/tokyonight-day.conf so the terminal
# and what runs inside it cannot disagree. # and what runs inside it cannot disagree.
# #
# Gradients keep the same low -> middle -> high meaning as the dark theme, using # Gradients keep the same low -> middle -> high meaning as the dark theme, using
+3 -23
View File
@@ -1,17 +1,9 @@
# espanso match file # espanso match file
# https://espanso.org/docs/ # https://espanso.org/docs/
# Name and email triggers (:fn, :em, ...) are NOT here: they are personal, and
# this file is shared. setup-identity seeds them into match/identity.yml --
# per-machine, gitignored, yours to edit -- from the install interview.
matches: matches:
# Name
- trigger: ":fn"
replace: "Gabriel Brown"
- trigger: ":fin"
replace: "Gabriel A Brown"
# Email
- trigger: ":em"
replace: "[email protected]"
- trigger: ":empro"
replace: "[email protected]"
# Date # Date
- trigger: ":date" - trigger: ":date"
replace: "{{mydate}}" replace: "{{mydate}}"
@@ -147,18 +139,6 @@ matches:
- trigger: "dokcer" - trigger: "dokcer"
replace: "docker" replace: "docker"
- trigger: "gborwn"
replace: "gbrown"
- trigger: "gbrwon"
replace: "gbrown"
- trigger: "gbronw"
replace: "gbrown"
- trigger: "grbown"
replace: "gbrown"
- trigger: "restrat" - trigger: "restrat"
replace: "restart" replace: "restart"
@@ -1,72 +0,0 @@
{
"overrides": [
{
"wmClass": "jetbrains-toolbox",
"mode": "float"
},
{
"wmClass": "Com.github.amezin.ddterm",
"mode": "float"
},
{
"wmClass": "Com.github.donadigo.eddy",
"mode": "float"
},
{
"wmClass": "Conky",
"mode": "float"
},
{
"wmClass": "Gnome-initial-setup",
"mode": "float"
},
{
"wmClass": "org.gnome.Calculator",
"mode": "float"
},
{
"wmClass": "gnome-terminal-preferences",
"mode": "float"
},
{
"wmClass": "Guake",
"mode": "float"
},
{
"wmClass": "zoom",
"mode": "float"
},
{
"wmClass": "mpv",
"mode": "float"
},
{
"wmClass": "Bitwarden",
"mode": "float"
},
{
"wmClass": "Hidamari",
"mode": "float"
},
{
"wmClass": "com.mattjakeman.ExtensionManager",
"mode": "float"
},
{
"wmClass": "Cider",
"mode": "float"
},
{
"wmClass": "Ulauncher",
"mode": "float"
},
{
"wmClass": "com.nextcloud.desktopclient.nextcloud",
"mode": "float"
},
{
"wmClass": "Emulator",
"mode": "float"
}
]
}
@@ -1,72 +0,0 @@
{
"overrides": [
{
"wmClass": "jetbrains-toolbox",
"mode": "float"
},
{
"wmClass": "Com.github.amezin.ddterm",
"mode": "float"
},
{
"wmClass": "Com.github.donadigo.eddy",
"mode": "float"
},
{
"wmClass": "Conky",
"mode": "float"
},
{
"wmClass": "Gnome-initial-setup",
"mode": "float"
},
{
"wmClass": "org.gnome.Calculator",
"mode": "float"
},
{
"wmClass": "gnome-terminal-preferences",
"mode": "float"
},
{
"wmClass": "Guake",
"mode": "float"
},
{
"wmClass": "zoom",
"mode": "float"
},
{
"wmClass": "mpv",
"mode": "float"
},
{
"wmClass": "Bitwarden",
"mode": "float"
},
{
"wmClass": "Hidamari",
"mode": "float"
},
{
"wmClass": "com.mattjakeman.ExtensionManager",
"mode": "float"
},
{
"wmClass": "Cider",
"mode": "float"
},
{
"wmClass": "Ulauncher",
"mode": "float"
},
{
"wmClass": "com.nextcloud.desktopclient.nextcloud",
"mode": "float"
},
{
"wmClass": "Emulator",
"mode": "float"
}
]
}
@@ -1,72 +0,0 @@
{
"overrides": [
{
"wmClass": "jetbrains-toolbox",
"mode": "float"
},
{
"wmClass": "Com.github.amezin.ddterm",
"mode": "float"
},
{
"wmClass": "Com.github.donadigo.eddy",
"mode": "float"
},
{
"wmClass": "Conky",
"mode": "float"
},
{
"wmClass": "Gnome-initial-setup",
"mode": "float"
},
{
"wmClass": "org.gnome.Calculator",
"mode": "float"
},
{
"wmClass": "gnome-terminal-preferences",
"mode": "float"
},
{
"wmClass": "Guake",
"mode": "float"
},
{
"wmClass": "zoom",
"mode": "float"
},
{
"wmClass": "mpv",
"mode": "float"
},
{
"wmClass": "Bitwarden",
"mode": "float"
},
{
"wmClass": "Hidamari",
"mode": "float"
},
{
"wmClass": "com.mattjakeman.ExtensionManager",
"mode": "float"
},
{
"wmClass": "Cider",
"mode": "float"
},
{
"wmClass": "Ulauncher",
"mode": "float"
},
{
"wmClass": "com.nextcloud.desktopclient.nextcloud",
"mode": "float"
},
{
"wmClass": "Emulator",
"mode": "float"
}
]
}
@@ -1,72 +0,0 @@
{
"overrides": [
{
"wmClass": "jetbrains-toolbox",
"mode": "float"
},
{
"wmClass": "Com.github.amezin.ddterm",
"mode": "float"
},
{
"wmClass": "Com.github.donadigo.eddy",
"mode": "float"
},
{
"wmClass": "Conky",
"mode": "float"
},
{
"wmClass": "Gnome-initial-setup",
"mode": "float"
},
{
"wmClass": "org.gnome.Calculator",
"mode": "float"
},
{
"wmClass": "gnome-terminal-preferences",
"mode": "float"
},
{
"wmClass": "Guake",
"mode": "float"
},
{
"wmClass": "zoom",
"mode": "float"
},
{
"wmClass": "mpv",
"mode": "float"
},
{
"wmClass": "Bitwarden",
"mode": "float"
},
{
"wmClass": "Hidamari",
"mode": "float"
},
{
"wmClass": "com.mattjakeman.ExtensionManager",
"mode": "float"
},
{
"wmClass": "Cider",
"mode": "float"
},
{
"wmClass": "Ulauncher",
"mode": "float"
},
{
"wmClass": "com.nextcloud.desktopclient.nextcloud",
"mode": "float"
},
{
"wmClass": "Emulator",
"mode": "float"
}
]
}
@@ -1,72 +0,0 @@
{
"overrides": [
{
"wmClass": "jetbrains-toolbox",
"mode": "float"
},
{
"wmClass": "Com.github.amezin.ddterm",
"mode": "float"
},
{
"wmClass": "Com.github.donadigo.eddy",
"mode": "float"
},
{
"wmClass": "Conky",
"mode": "float"
},
{
"wmClass": "Gnome-initial-setup",
"mode": "float"
},
{
"wmClass": "org.gnome.Calculator",
"mode": "float"
},
{
"wmClass": "gnome-terminal-preferences",
"mode": "float"
},
{
"wmClass": "Guake",
"mode": "float"
},
{
"wmClass": "zoom",
"mode": "float"
},
{
"wmClass": "mpv",
"mode": "float"
},
{
"wmClass": "Bitwarden",
"mode": "float"
},
{
"wmClass": "Hidamari",
"mode": "float"
},
{
"wmClass": "com.mattjakeman.ExtensionManager",
"mode": "float"
},
{
"wmClass": "Cider",
"mode": "float"
},
{
"wmClass": "Ulauncher",
"mode": "float"
},
{
"wmClass": "com.nextcloud.desktopclient.nextcloud",
"mode": "float"
},
{
"wmClass": "Emulator",
"mode": "float"
}
]
}
@@ -1,72 +0,0 @@
{
"overrides": [
{
"wmClass": "jetbrains-toolbox",
"mode": "float"
},
{
"wmClass": "Com.github.amezin.ddterm",
"mode": "float"
},
{
"wmClass": "Com.github.donadigo.eddy",
"mode": "float"
},
{
"wmClass": "Conky",
"mode": "float"
},
{
"wmClass": "Gnome-initial-setup",
"mode": "float"
},
{
"wmClass": "org.gnome.Calculator",
"mode": "float"
},
{
"wmClass": "gnome-terminal-preferences",
"mode": "float"
},
{
"wmClass": "Guake",
"mode": "float"
},
{
"wmClass": "zoom",
"mode": "float"
},
{
"wmClass": "mpv",
"mode": "float"
},
{
"wmClass": "Bitwarden",
"mode": "float"
},
{
"wmClass": "Hidamari",
"mode": "float"
},
{
"wmClass": "com.mattjakeman.ExtensionManager",
"mode": "float"
},
{
"wmClass": "Cider",
"mode": "float"
},
{
"wmClass": "Ulauncher",
"mode": "float"
},
{
"wmClass": "com.nextcloud.desktopclient.nextcloud",
"mode": "float"
},
{
"wmClass": "Emulator",
"mode": "float"
}
]
}
-76
View File
@@ -1,76 +0,0 @@
{
"overrides": [
{
"wmClass": "jetbrains-toolbox",
"mode": "float"
},
{
"wmClass": "Com.github.amezin.ddterm",
"mode": "float"
},
{
"wmClass": "Com.github.donadigo.eddy",
"mode": "float"
},
{
"wmClass": "Conky",
"mode": "float"
},
{
"wmClass": "Gnome-initial-setup",
"mode": "float"
},
{
"wmClass": "org.gnome.Calculator",
"mode": "float"
},
{
"wmClass": "gnome-terminal-preferences",
"mode": "float"
},
{
"wmClass": "Guake",
"mode": "float"
},
{
"wmClass": "zoom",
"mode": "float"
},
{
"wmClass": "Bitwarden",
"mode": "float"
},
{
"wmClass": "Hidamari",
"mode": "float"
},
{
"wmClass": "com.mattjakeman.ExtensionManager",
"mode": "float"
},
{
"wmClass": "Cider",
"mode": "float"
},
{
"wmClass": "Ulauncher",
"mode": "float"
},
{
"wmClass": "com.nextcloud.desktopclient.nextcloud",
"mode": "float"
},
{
"wmClass": "mpv",
"mode": "float"
},
{
"wmClass": "Spotify",
"mode": "float"
},
{
"wmClass": "bitwarden",
"mode": "float"
}
]
}
@@ -1,190 +0,0 @@
.tiled {
color: rgba(236, 94, 94, 1);
opacity: 1;
border-width: 3px;
}
.split {
color: rgba(255, 246, 108, 1);
opacity: 1;
border-width: 3px;
}
.stacked {
color: rgba(247, 162, 43, 1);
opacity: 1;
border-width: 3px;
}
.tabbed {
color: rgba(17, 199, 224, 1);
opacity: 1;
border-width: 3px;
}
.floated {
color: rgba(180, 167, 214, 1);
border-width: 3px;
opacity: 1;
}
.window-tiled-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-split-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
/* forge-36le: St does not paint an asymmetric (single-side) border when a
* border-radius is set, so the directional hint never rendered. Drop the radius
* on the one-edge variants; St paints a straight single-side border reliably. */
.window-split-horizontal {
border-left-width: 0;
border-top-width: 0;
border-bottom-width: 0;
border-radius: 0;
}
.window-split-vertical {
border-left-width: 0;
border-top-width: 0;
border-right-width: 0;
border-radius: 0;
}
.window-stacked-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-tabbed-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-tabbed-bg {
border-radius: 8px;
}
.window-tabbed-tab {
background-color: rgba(54, 47, 45, 1);
border-color: rgba(98,160,234,0.6);
border-width: 1px;
border-radius: 8px;
color: white;
margin: 1px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.window-tabbed-tab-active {
background-color: rgb(98,160,234);
color: black;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.window-tabbed-tab-close {
padding: 3px;
margin: 4px;
border-radius: 16px;
width: 16px;
background-color: #e06666;
}
.window-tabbed-tab-icon {
margin: 3px;
}
.window-floated-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-tilepreview-tiled {
border-width: 1px;
border-color: rgba(98,160,234,0.3);
border-style: solid;
border-radius: 14px;
background-color: rgba(98,160,234,0.2);
}
.window-tilepreview-stacked {
border-width: 1px;
border-color: rgba(98,160,234,0.3);
border-style: solid;
border-radius: 14px;
background-color: rgba(98,160,234,0.2);
}
.window-tilepreview-swap {
border-width: 1px;
border-color: rgba(162, 247, 43, 0.4);
border-style: solid;
border-radius: 14px;
background-color: rgba(162, 247, 43, 0.4);
}
.window-tilepreview-tabbed {
border-width: 1px;
border-color: rgba(98,160,234,0.3);
border-style: solid;
border-radius: 14px;
background-color: rgba(98,160,234,0.2);
}
/* Keybindings Cheatsheet Overlay */
.forge-cheatsheet {
background-color: #2d2d2d;
border-radius: 16px;
padding: 24px 32px;
border: 1px solid #4d4d4d;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.forge-cheatsheet-title {
font-size: 16px;
font-weight: bold;
color: #ffffff;
margin-bottom: 20px;
text-align: center;
}
.forge-cheatsheet-category {
font-size: 13px;
font-weight: bold;
color: #78aeed;
margin-top: 16px;
margin-bottom: 8px;
}
.forge-cheatsheet-row {
font-size: 12px;
color: #eeeeec;
margin: 4px 0;
padding: 2px 0;
}
.forge-cheatsheet-key {
font-family: monospace;
font-weight: bold;
color: #f5c211;
min-width: 180px;
}
.forge-cheatsheet-desc {
color: #d0cfcc;
}
@@ -1,190 +0,0 @@
.tiled {
color: rgba(236, 94, 94, 1);
opacity: 1;
border-width: 3px;
}
.split {
color: rgba(255, 246, 108, 1);
opacity: 1;
border-width: 3px;
}
.stacked {
color: rgba(247, 162, 43, 1);
opacity: 1;
border-width: 3px;
}
.tabbed {
color: rgba(17, 199, 224, 1);
opacity: 1;
border-width: 3px;
}
.floated {
color: rgba(180, 167, 214, 1);
border-width: 3px;
opacity: 1;
}
.window-tiled-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-split-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
/* forge-36le: St does not paint an asymmetric (single-side) border when a
* border-radius is set, so the directional hint never rendered. Drop the radius
* on the one-edge variants; St paints a straight single-side border reliably. */
.window-split-horizontal {
border-left-width: 0;
border-top-width: 0;
border-bottom-width: 0;
border-radius: 0;
}
.window-split-vertical {
border-left-width: 0;
border-top-width: 0;
border-right-width: 0;
border-radius: 0;
}
.window-stacked-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-tabbed-border {
border-width: 3px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-tabbed-bg {
border-radius: 8px;
}
.window-tabbed-tab {
background-color: rgba(54, 47, 45, 1);
border-color: rgba(98,160,234,0.6);
border-width: 1px;
border-radius: 8px;
color: white;
margin: 1px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.window-tabbed-tab-active {
background-color: rgb(98,160,234);
color: black;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2);
}
.window-tabbed-tab-close {
padding: 3px;
margin: 4px;
border-radius: 16px;
width: 16px;
background-color: #e06666;
}
.window-tabbed-tab-icon {
margin: 3px;
}
.window-floated-border {
border-width: 1px;
border-color: rgb(98,160,234);
border-style: solid;
border-radius: 14px;
}
.window-tilepreview-tiled {
border-width: 1px;
border-color: rgba(98,160,234,0.3);
border-style: solid;
border-radius: 14px;
background-color: rgba(98,160,234,0.2);
}
.window-tilepreview-stacked {
border-width: 1px;
border-color: rgba(98,160,234,0.3);
border-style: solid;
border-radius: 14px;
background-color: rgba(98,160,234,0.2);
}
.window-tilepreview-swap {
border-width: 1px;
border-color: rgba(162, 247, 43, 0.4);
border-style: solid;
border-radius: 14px;
background-color: rgba(162, 247, 43, 0.4);
}
.window-tilepreview-tabbed {
border-width: 1px;
border-color: rgba(98,160,234,0.3);
border-style: solid;
border-radius: 14px;
background-color: rgba(98,160,234,0.2);
}
/* Keybindings Cheatsheet Overlay */
.forge-cheatsheet {
background-color: #2d2d2d;
border-radius: 16px;
padding: 24px 32px;
border: 1px solid #4d4d4d;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.forge-cheatsheet-title {
font-size: 16px;
font-weight: bold;
color: #ffffff;
margin-bottom: 20px;
text-align: center;
}
.forge-cheatsheet-category {
font-size: 13px;
font-weight: bold;
color: #78aeed;
margin-top: 16px;
margin-bottom: 8px;
}
.forge-cheatsheet-row {
font-size: 12px;
color: #eeeeec;
margin: 4px 0;
padding: 2px 0;
}
.forge-cheatsheet-key {
font-family: monospace;
font-weight: bold;
color: #f5c211;
min-width: 180px;
}
.forge-cheatsheet-desc {
color: #d0cfcc;
}
-8
View File
@@ -1,8 +0,0 @@
file:///home/gib/Documents Documents
file:///home/gib/Documents/Code Code
file:///home/gib/Downloads Downloads
file:///home/gib/Music Music
file:///home/gib/Pictures Pictures
file:///home/gib/Videos Videos
file:///home/gib/.local/share/Panama Panama
smb://server-gib.local/homes/ Gib's Server
+6
View File
@@ -0,0 +1,6 @@
file://$HOME/Documents Documents
file://$HOME/Downloads Downloads
file://$HOME/Music Music
file://$HOME/Pictures Pictures
file://$HOME/Videos Videos
file://$HOME/.local/share/Panama Panama
+60 -33
View File
@@ -1,36 +1,63 @@
@define-color accent_color rgba(192,153,255,0.8); /* Panama regenerates the block below from the active theme's palette, and only
@define-color accent_bg_color rgba(130,170,255,0.8); * the block: every rule outside the markers belongs to the vendored theme and
@define-color accent_fg_color #ffffff; * is never touched. Edit the theme, not this file -- the next theme change
@define-color destructive_color #ff7b63; * overwrites whatever is between the markers. The values shipped here are Tokyo
@define-color destructive_bg_color #c01c28; * Moon, the default dark theme, so the file is valid before it has ever been
@define-color destructive_fg_color #ffffff; * rewritten. See quickshell/scripts/panama-theme-apps. */
@define-color success_color #8ff0a4; /* PANAMA THEME BEGIN */
@define-color success_bg_color #26a269; /* Generated from the "moon" theme by panama-theme-apps. Edit the
@define-color success_fg_color #ffffff; * theme, not this block -- the next theme change overwrites it. */
@define-color warning_color #f8e45c; @define-color accent_bg_color #82aaff;
@define-color warning_bg_color #cd9309; @define-color accent_color #65bcff;
@define-color warning_fg_color rgba(0, 0, 0, 0.8); @define-color accent_fg_color #1e2030;
@define-color error_color #ff7b63; @define-color destructive_bg_color #c53b53;
@define-color error_bg_color #c01c28; @define-color destructive_color #ff757f;
@define-color error_fg_color #ffffff; @define-color destructive_fg_color #c8d3f5;
@define-color window_bg_color rgba(46,47,61,0.98); @define-color success_bg_color #c3e88d;
@define-color window_fg_color #ffffff; @define-color success_color #c3e88d;
@define-color view_bg_color rgba(46,47,61,0.98); @define-color success_fg_color #1e2030;
@define-color view_fg_color #ffffff; @define-color warning_bg_color #ffc777;
@define-color headerbar_bg_color rgba(46,47,61,0.98); @define-color warning_color #ffc777;
@define-color headerbar_fg_color #ffffff; @define-color warning_fg_color #1e2030;
@define-color headerbar_border_color #ffffff; @define-color error_bg_color #c53b53;
@define-color headerbar_backdrop_color @window_bg_color; @define-color error_color #ff757f;
@define-color headerbar_shade_color rgba(46,47,61,0.05); @define-color error_fg_color #c8d3f5;
@define-color card_bg_color rgba(180, 200, 255, 0.15); @define-color window_bg_color #2e2f3d;
@define-color card_fg_color #ffffff; @define-color window_fg_color #c8d3f5;
@define-color card_shade_color rgba(0, 0, 0, 0.36); @define-color view_bg_color #222436;
@define-color dialog_bg_color rgba(46,47,61,0.98); @define-color view_fg_color #c8d3f5;
@define-color dialog_fg_color #ffffff; @define-color headerbar_bg_color #2e2f3d;
@define-color popover_bg_color rgba(46,47,61,0.98); @define-color headerbar_fg_color #c8d3f5;
@define-color popover_fg_color #ffffff; @define-color headerbar_border_color #3b4261;
@define-color shade_color rgba(0, 0, 0, 0); @define-color headerbar_backdrop_color #1e2030;
@define-color scrollbar_outline_color rgba(0, 0, 0, 0.5); @define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
@define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
@define-color sidebar_bg_color #1e2030;
@define-color sidebar_fg_color #c8d3f5;
@define-color sidebar_backdrop_color #1e2030;
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color secondary_sidebar_bg_color #1e2030;
@define-color secondary_sidebar_fg_color #c8d3f5;
@define-color secondary_sidebar_backdrop_color #1e2030;
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color card_bg_color #2f334d;
@define-color card_fg_color #c8d3f5;
@define-color card_shade_color rgba(30, 32, 48, 0.25);
@define-color dialog_bg_color #21212f;
@define-color dialog_fg_color #c8d3f5;
@define-color popover_bg_color #21212f;
@define-color popover_fg_color #c8d3f5;
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
@define-color thumbnail_bg_color #2f334d;
@define-color thumbnail_fg_color #c8d3f5;
@define-color shade_color rgba(30, 32, 48, 0.25);
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
/* PANAMA THEME END */
/* The GNOME named palette below is reference, not theme: applications ask for
* @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color blue_1 #99c1f1; @define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea; @define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4; @define-color blue_3 #3584e4;
+1 -1
View File
@@ -1,6 +1,6 @@
# GENERATED FILE -- edit settings.ini.template instead. # GENERATED FILE -- edit settings.ini.template instead.
# #
# The theme name and dark preference below follow Panama's colour # The theme name and dark preference below follow Panama's color
# scheme, so this file is regenerated on every switch and is not # scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over # committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no # XSETTINGS and this file is ignored; under Hyprland there is no
Executable → Regular
+58 -47
View File
@@ -1,53 +1,64 @@
/* GTK NAMED COLORS ---------------- use responsibly! */ /* GTK NAMED COLORS ---------------- use responsibly! */
@define-color accent_bg_color RGB(130 170 255 / 80%); /* Panama regenerates the block below from the active theme's palette, and only
* the block: every rule outside the markers belongs to the vendored theme and
@define-color destructive_bg_color #c01c28; * is never touched. Edit the theme, not this file -- the next theme change
@define-color destructive_fg_color white; * overwrites whatever is between the markers. The values shipped here are Tokyo
@define-color success_bg_color #26a269; * Moon, the default dark theme, so the file is valid before it has ever been
@define-color success_fg_color white; * rewritten. See quickshell/scripts/panama-theme-apps. */
@define-color warning_bg_color #cd9309; /* PANAMA THEME BEGIN */
@define-color warning_fg_color RGB(0 0 0 / 80%); /* Generated from the "moon" theme by panama-theme-apps. Edit the
@define-color error_bg_color #c01c28; * theme, not this block -- the next theme change overwrites it. */
@define-color error_fg_color white; @define-color accent_bg_color #82aaff;
@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b); @define-color accent_color #65bcff;
@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b); @define-color accent_fg_color #1e2030;
@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b); @define-color destructive_bg_color #c53b53;
@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b); @define-color destructive_color #ff757f;
@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b); @define-color destructive_fg_color #c8d3f5;
@define-color success_bg_color #c3e88d;
@define-color success_color #c3e88d;
@define-color success_fg_color #1e2030;
@define-color warning_bg_color #ffc777;
@define-color warning_color #ffc777;
@define-color warning_fg_color #1e2030;
@define-color error_bg_color #c53b53;
@define-color error_color #ff757f;
@define-color error_fg_color #c8d3f5;
@define-color window_bg_color #2e2f3d; @define-color window_bg_color #2e2f3d;
@define-color window_fg_color white; @define-color window_fg_color #c8d3f5;
@define-color view_bg_color @window_bg_color @define-color view_bg_color #222436;
@define-color view_fg_color white; @define-color view_fg_color #c8d3f5;
@define-color headerbar_bg_color @window_bg_color @define-color headerbar_bg_color #2e2f3d;
@define-color headerbar_fg_color white; @define-color headerbar_fg_color #c8d3f5;
@define-color headerbar_border_color white; @define-color headerbar_border_color #3b4261;
@define-color headerbar_backdrop_color @window_bg_color; @define-color headerbar_backdrop_color #1e2030;
@define-color headerbar_shade_color RGB(46 47 61 / 8%); @define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9); @define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
@define-color sidebar_bg_color #1e2030;
@define-color sidebar_fg_color #c8d3f5;
@define-color sidebar_backdrop_color #1e2030;
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color secondary_sidebar_bg_color #1e2030;
@define-color secondary_sidebar_fg_color #c8d3f5;
@define-color secondary_sidebar_backdrop_color #1e2030;
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color card_bg_color #2f334d;
@define-color card_fg_color #c8d3f5;
@define-color card_shade_color rgba(30, 32, 48, 0.25);
@define-color dialog_bg_color #21212f;
@define-color dialog_fg_color #c8d3f5;
@define-color popover_bg_color #21212f;
@define-color popover_fg_color #c8d3f5;
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
@define-color thumbnail_bg_color #2f334d;
@define-color thumbnail_fg_color #c8d3f5;
@define-color shade_color rgba(30, 32, 48, 0.25);
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
/* PANAMA THEME END */
@define-color sidebar_bg_color #3c3c4b; /* The GNOME named palette below is reference, not theme: applications ask for
@define-color sidebar_fg_color white; * @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color sidebar_backdrop_color #3c3c4b;
@define-color sidebar_shade_color RGB(60 60 75/8%);
@define-color sidebar_border_color RGB(75 75 90/36%);
@define-color secondary_sidebar_bg_color #3c3c4b;
@define-color secondary_sidebar_fg_color white;
@define-color secondary_sidebar_backdrop_color #3c3c4b;
@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
@define-color card_bg_color RGB(180 200 255/15%);
@define-color card_fg_color white;
@define-color card_shade_color RGB(46 47 61/8%);
@define-color dialog_bg_color @window_bg_color;
@define-color dialog_fg_color white;
@define-color popover_bg_color @window_bg_color;
@define-color popover_fg_color white;
@define-color popover_shade_color RGB(0 0 6/25%);
@define-color thumbnail_bg_color @window_bg_color;
@define-color thumbnail_fg_color white;
@define-color shade_color RGB(0 0 6/25%);
@define-color scrollbar_outline_color RGB(0 0 12/95%);
@define-color blue_1 #99c1f1; @define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea; @define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4; @define-color blue_3 #3584e4;
Executable → Regular
+58 -47
View File
@@ -1,53 +1,64 @@
/* GTK NAMED COLORS ---------------- use responsibly! */ /* GTK NAMED COLORS ---------------- use responsibly! */
@define-color accent_bg_color RGB(130 170 255 / 80%); /* Panama regenerates the block below from the active theme's palette, and only
* the block: every rule outside the markers belongs to the vendored theme and
@define-color destructive_bg_color #c01c28; * is never touched. Edit the theme, not this file -- the next theme change
@define-color destructive_fg_color white; * overwrites whatever is between the markers. The values shipped here are Tokyo
@define-color success_bg_color #26a269; * Moon, the default dark theme, so the file is valid before it has ever been
@define-color success_fg_color white; * rewritten. See quickshell/scripts/panama-theme-apps. */
@define-color warning_bg_color #cd9309; /* PANAMA THEME BEGIN */
@define-color warning_fg_color RGB(0 0 0 / 80%); /* Generated from the "moon" theme by panama-theme-apps. Edit the
@define-color error_bg_color #c01c28; * theme, not this block -- the next theme change overwrites it. */
@define-color error_fg_color white; @define-color accent_bg_color #82aaff;
@define-color accent_color oklab(from @accent_bg_color max(l, 0.85) a b); @define-color accent_color #65bcff;
@define-color destructive_color oklab(from @destructive_bg_color max(l, 0.85) a b); @define-color accent_fg_color #1e2030;
@define-color success_color oklab(from @success_bg_color max(l, 0.85) a b); @define-color destructive_bg_color #c53b53;
@define-color warning_color oklab(from @warning_bg_color max(l, 0.85) a b); @define-color destructive_color #ff757f;
@define-color error_color oklab(from @error_bg_color max(l, 0.85) a b); @define-color destructive_fg_color #c8d3f5;
@define-color success_bg_color #c3e88d;
@define-color success_color #c3e88d;
@define-color success_fg_color #1e2030;
@define-color warning_bg_color #ffc777;
@define-color warning_color #ffc777;
@define-color warning_fg_color #1e2030;
@define-color error_bg_color #c53b53;
@define-color error_color #ff757f;
@define-color error_fg_color #c8d3f5;
@define-color window_bg_color #2e2f3d; @define-color window_bg_color #2e2f3d;
@define-color window_fg_color white; @define-color window_fg_color #c8d3f5;
@define-color view_bg_color @window_bg_color @define-color view_bg_color #222436;
@define-color view_fg_color white; @define-color view_fg_color #c8d3f5;
@define-color headerbar_bg_color @window_bg_color @define-color headerbar_bg_color #2e2f3d;
@define-color headerbar_fg_color white; @define-color headerbar_fg_color #c8d3f5;
@define-color headerbar_border_color white; @define-color headerbar_border_color #3b4261;
@define-color headerbar_backdrop_color @window_bg_color; @define-color headerbar_backdrop_color #1e2030;
@define-color headerbar_shade_color RGB(46 47 61 / 8%); @define-color headerbar_shade_color rgba(30, 32, 48, 0.08);
@define-color headerbar_darker_shade_color RGB(0, 0, 6, 0.9); @define-color headerbar_darker_shade_color rgba(30, 32, 48, 0.9);
@define-color sidebar_bg_color #1e2030;
@define-color sidebar_fg_color #c8d3f5;
@define-color sidebar_backdrop_color #1e2030;
@define-color sidebar_shade_color rgba(59, 66, 97, 0.08);
@define-color sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color secondary_sidebar_bg_color #1e2030;
@define-color secondary_sidebar_fg_color #c8d3f5;
@define-color secondary_sidebar_backdrop_color #1e2030;
@define-color secondary_sidebar_shade_color rgba(30, 32, 48, 0.25);
@define-color secondary_sidebar_border_color rgba(59, 66, 97, 0.36);
@define-color card_bg_color #2f334d;
@define-color card_fg_color #c8d3f5;
@define-color card_shade_color rgba(30, 32, 48, 0.25);
@define-color dialog_bg_color #21212f;
@define-color dialog_fg_color #c8d3f5;
@define-color popover_bg_color #21212f;
@define-color popover_fg_color #c8d3f5;
@define-color popover_shade_color rgba(30, 32, 48, 0.25);
@define-color thumbnail_bg_color #2f334d;
@define-color thumbnail_fg_color #c8d3f5;
@define-color shade_color rgba(30, 32, 48, 0.25);
@define-color scrollbar_outline_color rgba(30, 32, 48, 0.5);
/* PANAMA THEME END */
@define-color sidebar_bg_color #3c3c4b; /* The GNOME named palette below is reference, not theme: applications ask for
@define-color sidebar_fg_color white; * @red_3 by name and expect GNOME's red. It stays outside the block. */
@define-color sidebar_backdrop_color #3c3c4b;
@define-color sidebar_shade_color RGB(60 60 75/8%);
@define-color sidebar_border_color RGB(75 75 90/36%);
@define-color secondary_sidebar_bg_color #3c3c4b;
@define-color secondary_sidebar_fg_color white;
@define-color secondary_sidebar_backdrop_color #3c3c4b;
@define-color secondary_sidebar_shade_color RGB(0 0 6/25%);
@define-color secondary_sidebar_border_color RGB(0 0 6/36%);
@define-color card_bg_color RGB(180 200 255/15%);
@define-color card_fg_color white;
@define-color card_shade_color RGB(46 47 61/8%);
@define-color dialog_bg_color @window_bg_color;
@define-color dialog_fg_color white;
@define-color popover_bg_color @window_bg_color;
@define-color popover_fg_color white;
@define-color popover_shade_color RGB(0 0 6/25%);
@define-color thumbnail_bg_color @window_bg_color;
@define-color thumbnail_fg_color white;
@define-color shade_color RGB(0 0 6/25%);
@define-color scrollbar_outline_color RGB(0 0 12/95%);
@define-color blue_1 #99c1f1; @define-color blue_1 #99c1f1;
@define-color blue_2 #62a0ea; @define-color blue_2 #62a0ea;
@define-color blue_3 #3584e4; @define-color blue_3 #3584e4;
+1 -1
View File
@@ -1,6 +1,6 @@
# GENERATED FILE -- edit settings.ini.template instead. # GENERATED FILE -- edit settings.ini.template instead.
# #
# The theme name and dark preference below follow Panama's colour # The theme name and dark preference below follow Panama's color
# scheme, so this file is regenerated on every switch and is not # scheme, so this file is regenerated on every switch and is not
# committed. Under GNOME, gnome-settings-daemon publishes these over # committed. Under GNOME, gnome-settings-daemon publishes these over
# XSETTINGS and this file is ignored; under Hyprland there is no # XSETTINGS and this file is ignored; under Hyprland there is no
+50 -11
View File
@@ -15,17 +15,17 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Hidden applications | GNOME App Hider list in Vicinae provider config | Live | | Hidden applications | GNOME App Hider list in Vicinae provider config | Live |
| Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live | | Tiling and dynamic workspaces | Hyprland Lua config, Forge keymap preserved | Live |
| Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live | | Overview | Continuum filmstrip, large workspace preview, cross-workspace search | Live |
| Minimise | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live | | Minimize | `SUPER+SHIFT+X` sends to scratchpad; `SUPER+X` restores | Live |
| Dock | Quickshell dock with GNOME favourites and intellihide | Live | | Dock | Quickshell dock with GNOME favorites and intellihide | Live |
| Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live | | Panel | Workspaces, CPU/RAM/GPU, clock, weather, quiet event capsule, media, clipboard, tray, status | Live |
| Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live | | Clipboard history | Vicinae owns capture/storage; Quickshell provides the panel | Live |
| Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live | | Notifications | Quickshell server, toasts, grouped history, DND, clear actions | Live |
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live | | Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live | | Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live | | Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live | | Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favorites, and KDE Connect phone continuity | Live |
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live | | Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor | | Brightness | Appears only when a backlight device exists | Live where the hardware exposes it |
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live | | AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
| Screenshot and recording | Quickshell picker, grim/slurp/satty/wf-recorder | Live | | Screenshot and recording | Quickshell picker, grim/slurp/satty/wf-recorder | Live |
| Screen text and code recognition | Local Tesseract/ZBar Read mode, selectable Prism result sheet | Live after package install | | Screen text and code recognition | Local Tesseract/ZBar Read mode, selectable Prism result sheet | Live after package install |
@@ -36,14 +36,37 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
| Removable media | udiskie plus udisks notifications | Live | | Removable media | udiskie plus udisks notifications | Live |
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live | | Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live | | Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
| System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live | | System settings | The Settings app for display policy, appearance, desktop, sound, focus, shortcuts, and services; labeled GNOME hardware/account handoffs | Live |
| System health and recovery | Settings → System Health, `Panama: Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live | | System health and recovery | Settings → System Health, `Check System Health` in Vicinae, a degraded-only bar indicator, redacted reports, and bounded Panama-owned repairs | Live |
## Portable machines
Panama was a desktop configuration until 2026-08-22, in the code as well as on
the machine: there was no reference to a battery, a lid, or a power source
anywhere in it, and `hypridle.conf` said "This is a desktop" in its own header.
| Capability | Panama implementation | Status |
|---|---|---|
| Battery level | `services/Battery.qml` reads sysfs the way Vitals reads procfs; bar indicator hides entirely without a battery | Live |
| Power source | `panama-hw ac`; a machine with no mains supply reports as on wall power, so a desktop is never treated as running out | Live |
| Charge threshold | Power page control, only where the firmware exposes a ceiling; written through `panama-sudo` | Live |
| Idle timings per power source | hypridle holds one set at a time, so `panama-idle` rebuilds its config when the charger comes or goes | Live |
| Lock on lid close | hypridle's `before_sleep_cmd` already locks, so a lid-close suspend is a locked suspend | Live |
| Clamshell / docked | A logind `handle-lid-switch` inhibitor held while an external display is connected. No lid watcher, and a failure returns logind's default | Live, untested on a lid |
| Display arrangement on reconnect | `Displays.plannedRestore()` reapplies a confirmed layout, refusing when the stored mode is not offered | Live |
| Charger and low-battery notices | `StatusEvents`; the charger is ambient so DND quiets it, critical battery is not | Live |
| Hardware predicates | `panama-hw`: laptop, battery, ac, lid-closed, external-monitor, clamshell, touchpad, nvidia | Live |
The clamshell path is the one row that has not run on hardware with a lid.
Everything testable about it is tested; the end-to-end behaviour of closing a
real lid on a docked machine is not, and the helper's header says so rather
than letting silence imply otherwise.
## System health and recovery ## System health and recovery
Panama stays silent while the desktop is healthy. A compact bar indicator Panama stays silent while the desktop is healthy. A compact bar indicator
appears only for actionable warnings or errors and opens the same **System appears only for actionable warnings or errors and opens the same **System
Health** page available from Settings and the Vicinae command **Panama: Check Health** page available from Settings and the Vicinae command **Check
System Health**. The terminal summary is available with: System Health**. The terminal summary is available with:
```bash ```bash
@@ -59,7 +82,7 @@ They never install packages, invoke `sudo`, delete user data, or rewrite
arbitrary configuration. arbitrary configuration.
Generic Fedora configuration remains with the system tools that own it. The Generic Fedora configuration remains with the system tools that own it. The
final System Health card hands network settings, users, sharing, colour final System Health card hands network settings, users, sharing, color
profiles, and digital wellbeing to their exact GNOME Settings panels rather profiles, and digital wellbeing to their exact GNOME Settings panels rather
than presenting inert Hyprland controls. than presenting inert Hyprland controls.
@@ -77,9 +100,9 @@ than presenting inert Hyprland controls.
| Caffeine | Replaced by a real logind inhibitor in quick settings | | Caffeine | Replaced by a real logind inhibitor in quick settings |
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur | | Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker | | Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface | | Wi-Fi QR | Replaced by an on-demand QR-code sharing flow in Control Center's Wi-Fi panel; the code is generated only while shown and written to tmpfs, never persisted |
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable | | GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup | | Home Assistant | Replaced by secure favorites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port | | Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
| App Hider | Hidden app list is now applied in Vicinae | | App Hider | Hidden app list is now applied in Vicinae |
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test | | Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
@@ -91,6 +114,22 @@ a polished general-purpose desktop can go beyond the current shell.
1. A real external RustDesk test while hyprlock is active. Local process state 1. A real external RustDesk test while hyprlock is active. Local process state
cannot prove what a remote client sees or whether input is accepted. cannot prove what a remote client sees or whether input is accepted.
2. Closing a real lid on a docked laptop. The inhibitor, the predicates and the
decision are all tested; the physical gesture is not.
3. A keyboard-layout change notice. Hyprland reports the active keymap but not
a change event Quickshell already consumes, so this needs either polling or
new event plumbing, and a single-layout machine cannot test it.
4. Sticky keys, slow keys and bounce keys. Wayland has no protocol for these,
so each compositor implements them for itself — mutter does, which is how
GNOME has them on Wayland, and Hyprland does not. There is no XKB option to
lean on either: the accessx option group is X11-only and does not appear in
evdev.lst at all (checked, not assumed — `grep -c accessx
/usr/share/X11/xkb/rules/evdev.lst` is 0), and Hyprland will happily store
`accessx:enable` as a keyboard option that nothing ever acts on. This is a
Hyprland gap rather than a Wayland impossibility, and the Accessibility page
says so in those terms; an earlier version of this line blamed X11 and sent
anyone who needs sticky keys to the wrong conclusion about the platform.
5. An on-screen keyboard, for a touch or convertible machine.
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
tracked in `setup/packages/hyprland-packages`. tracked in `setup/packages/hyprland-packages`.
@@ -111,4 +150,4 @@ menu for a minority, which is worse than omitting it. Relevant references:
- <https://github.com/hyprwm/Hyprland/discussions/1358> - <https://github.com/hyprwm/Hyprland/discussions/1358>
A future focused-window menu could expose compositor actions such as float, A future focused-window menu could expose compositor actions such as float,
move, fullscreen, and close. It should not be labelled as an application menu. move, fullscreen, and close. It should not be labeled as an application menu.
+25 -21
View File
@@ -29,13 +29,15 @@ Don't "fix" them.
| `hyprland.lua` | Entry point. Each `require()` is its own error scope | | `hyprland.lua` | Entry point. Each `require()` is its own error scope |
| `prefs.lua` | Reads the settings file the Settings app writes. See below | | `prefs.lua` | Reads the settings file the Settings app writes. See below |
| `env.lua` | Environment. Note the uwsm caveat below | | `env.lua` | Environment. Note the uwsm caveat below |
| `monitors.lua` | DP-2 geometry, scaling, and the HDR decision | | `monitors.lua` | Monitor geometry and scaling (the Kuycon by description), and the HDR decision |
| `looks.lua` | Colours, blur, glow, shadows, animations, VRR, scanout | | `looks.lua` | Colors, blur, glow, shadows, animations, VRR, scanout |
| `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME | | `input.lua` | Keyboard/mouse. Click-to-focus, like GNOME |
| `rules.lua` | Window rules, gaming rules, layer rules for the shell | | `rules.lua` | Window rules, gaming rules, layer rules for the shell |
| `keybinds.lua` | The full keymap | | `keybinds.lua` | The full keymap, including the custom shortcuts the Settings app stores |
| `actions.lua` | Named-action resolver: the whitelist tables that turn stored `{kind, target}` data into binds, gestures — never into free-form commands |
| `autostart.lua` | Session startup | | `autostart.lua` | Session startup |
| `overrides.lua` | Per-machine escape hatch, loaded last | | `overrides.lua` | Per-machine escape hatch, loaded last |
| `shaders/` | Whole-screen color-filter shaders (grayscale and the three color-blindness corrections) `looks.lua` maps the `colorFilter` setting onto |
| `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) | | `hyprlock.conf` / `hypridle.conf` / `hyprpaper.conf` / `hyprtoolkit.conf` | Ecosystem tools (hyprlang) |
Validate any change without leaving your session: Validate any change without leaving your session:
@@ -87,8 +89,8 @@ persistence, validation, reset, and the live write are all derived from that
entry. entry.
`prefs.lua` never raises. A missing, empty, truncated, malformed, or `prefs.lua` never raises. A missing, empty, truncated, malformed, or
wrong-typed settings file costs you your customisations and nothing else; wrong-typed settings file costs you your customizations and nothing else;
`tests/hypr/prefs-fallback-contract.sh` pins that, including that Hyprland still `tests/hypr/prefs-fallback-contract` pins that, including that Hyprland still
accepts the config in each of those states. accepts the config in each of those states.
### Adding a keybind: use `bind`, not `hl.bind` ### Adding a keybind: use `bind`, not `hl.bind`
@@ -119,8 +121,8 @@ shipped one, so a hand-edited `settings.json` cannot cost you a keymap.
Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds Every `hl.bind` must pass a `description`. Hyprland reports Lua-defined binds
with dispatcher `__lua` and a bytecode offset as the argument, so a bind without with dispatcher `__lua` and a bytecode offset as the argument, so a bind without
one has nothing readable beside its chord, and the Settings app drops it from the one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row. Keyboard page under Input rather than showing a mystery row.
`tests/quickshell/keybinds-contract.sh` fails if any bind lacks a description, so `tests/quickshell/keybinds-contract` fails if any bind lacks a description, so
this cannot regress silently. this cannot regress silently.
```lua ```lua
@@ -163,7 +165,7 @@ four places, and nowhere else —
dock, popovers), running blue on the left to orchid on the right and fading dock, popovers), running blue on the left to orchid on the right and fading
out before the corners. See `quickshell/widgets/PrismEdge.qml`. out before the corners. See `quickshell/widgets/PrismEdge.qml`.
2. The focused window's border — `general.col.active_border`, blue→orchid at 2. The focused window's border — `general.col.active_border`, blue→orchid at
115°. Unfocused windows get no colour at all, because the gradient only 115°. Unfocused windows get no color at all, because the gradient only
means something if one window on screen is wearing it. means something if one window on screen is wearing it.
3. The active workspace pill in the bar. 3. The active workspace pill in the bar.
4. Slider fills. 4. Slider fills.
@@ -179,9 +181,9 @@ which is the opposite of the intent.
The Nerd Font is still used, but only to draw **icon glyphs** — it is the The Nerd Font is still used, but only to draw **icon glyphs** — it is the
pragmatic alternative to freedesktop symbolic icons, which ship with a pragmatic alternative to freedesktop symbolic icons, which ship with a
hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolour. Where a hardcoded `#2e3436` fill that Qt (unlike GTK) will not recolor. Where a
themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml` themed freedesktop icon is wanted instead, `quickshell/widgets/ThemedIcon.qml`
paints a palette colour through the icon's alpha. paints a palette color through the icon's alpha.
Anything whose digits change in place — the clock, the vitals percentages, the Anything whose digits change in place — the clock, the vitals percentages, the
recording timer, the selection readout — sets `font.features: recording timer, the selection readout — sets `font.features:
@@ -247,7 +249,7 @@ The mental model is unchanged from Forge:
| `SUPER + SHIFT + H/J/K/L` | Move window | | `SUPER + SHIFT + H/J/K/L` | Move window |
| `SUPER + CTRL + H/J/K/L` | Swap window | | `SUPER + CTRL + H/J/K/L` | Swap window |
| `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower | | `SUPER + SHIFT + Y/O` · `B/M` | Wider · narrower |
| `SUPER + SHIFT + I/U` · `P/N` | Taller · shorter | | `SUPER + SHIFT + I/U` · `,/N` | Taller · shorter |
| `SUPER + [` / `]` / `=` | Shrink / expand / reset split | | `SUPER + [` / `]` / `=` | Shrink / expand / reset split |
| `SUPER + Q` | Close | | `SUPER + Q` | Close |
| `SUPER + U` | Fullscreen | | `SUPER + U` | Fullscreen |
@@ -256,7 +258,7 @@ The mental model is unchanged from Forge:
| `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down | | `SUPER + CTRL + G` / `Z` / `V` | Toggle split / preselect right / preselect down |
| `SUPER + Tab` / `SHIFT + Tab` | Cycle windows | | `SUPER + Tab` / `SHIFT + Tab` | Cycle windows |
| `SUPER + SHIFT + grave` | Last window | | `SUPER + SHIFT + grave` | Last window |
| `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimise to scratchpad | | `SUPER + X` / `SUPER + SHIFT + X` | Restore scratchpad / minimize to scratchpad |
### Workspaces (dynamic, like GNOME) ### Workspaces (dynamic, like GNOME)
| Key | Action | | Key | Action |
@@ -270,20 +272,22 @@ The mental model is unchanged from Forge:
### Launcher and shell ### Launcher and shell
| Key | Action | | Key | Action |
|---|---| |---|---|
| `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favourite | | `SUPER + A` / `R` / `Space` | Launcher (vicinae) — all three, pick your favorite |
| `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken | | `SUPER + SHIFT + R` | Fallback launcher (wofi) if the shell is broken |
| `SUPER + V` | Clipboard history | | `SUPER + V` | Clipboard history |
| `SUPER + .` | Emoji picker | | `SUPER + .` | Emoji picker |
| `SUPER + S` | Quick settings | | `SUPER + S` | Quick settings |
| `SUPER + I` | Settings | | `SUPER + I` | Settings |
| `SUPER + SHIFT + F` | Start or reveal focus session | | `SUPER + SHIFT + F` | Start or reveal focus session |
| `SUPER + B` | Notification centre | | `SUPER + B` | Notification center |
| `SUPER + grave` | Workspace overview | | `SUPER + grave` | Workspace overview |
| `Print` | Screenshot / record picker | | `Print` | Screenshot / record picker |
| `SHIFT` / `ALT + Print` | Screenshot screen / window immediately | | `SHIFT` / `ALT + Print` | Screenshot screen / window immediately |
| `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection | | `SUPER + SHIFT + S` | Screen Intelligence — read text and codes from a selection |
| `SUPER + SHIFT + P` | Colour picker | | `SUPER + SHIFT + P` | Color picker |
| `CTRL + ALT + L` | Lock (SUPER+L is "focus right") | | `CTRL + ALT + L` · `SUPER + Backspace` | Lock (SUPER+L is "focus right") |
| `SUPER + /` | Every shortcut, on screen. Reads the live keymap, so a rebind shows here |
| `SUPER + ALT + T/N/W/F/C/E` | Go to that application if it is open, rather than starting another |
| `CTRL + ALT + Delete` | Power menu | | `CTRL + ALT + Delete` | Power menu |
### Apps ### Apps
@@ -325,8 +329,8 @@ Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
one place. Home and Phone continue the same surface rather than opening extra one place. Home and Phone continue the same surface rather than opening extra
dashboard windows. dashboard windows.
Home shows the first four selected favourites at rest and every selected light Home shows the first four selected favorites at rest and every selected light
when expanded. Use **Settings → Home & Phone** to choose favourites, when expanded. Use **Settings → Home & Phone** to choose favorites,
set Panama-only aliases, and arrange their order. Dragging a brightness control set Panama-only aliases, and arrange their order. Dragging a brightness control
only previews the value; releasing it sends one brightness request. A normal only previews the value; releasing it sends one brightness request. A normal
power toggle leaves Home Assistant responsible for restoring its previous power toggle leaves Home Assistant responsible for restoring its previous
@@ -334,7 +338,7 @@ level.
Credentials stay private in the gitignored `config/bash/env` file, with the Credentials stay private in the gitignored `config/bash/env` file, with the
existing GNOME extension and Secret Service setup retained as a compatibility existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favourites, aliases, and order live in Quickshell state. No shell fallback. Favorites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action. with a stale-state label and Retry action.
@@ -361,10 +365,10 @@ These are the places a 1:1 port was impossible, and what was done instead:
- **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an - **Per-edge resize.** Forge resized one named edge; Hyprland resizes along an
axis and lets the layout pick the edge. The eight Forge keys collapse to four axis and lets the layout pick the edge. The eight Forge keys collapse to four
behaviours, keeping the horizontal/vertical and grow/shrink pairing. behaviors, keeping the horizontal/vertical and grow/shrink pairing.
- **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a - **Overview on `SUPER + grave`, not a bare SUPER tap.** Tap-detection on a
modifier misfires when you're quick with SUPER combos. modifier misfires when you're quick with SUPER combos.
- **Lock on `CTRL + ALT + L`.** `SUPER + L` is "focus right" in this keymap. - **Lock on `CTRL + ALT + L` or `SUPER + Backspace`.** `SUPER + L` is "focus right" in this keymap.
- **`SUPER + grave` was Forge's "cycle windows of same app"**, which Hyprland - **`SUPER + grave` was Forge's "cycle windows of same app"**, which Hyprland
has no equivalent for. "Last window" moved to `SUPER + SHIFT + grave`. has no equivalent for. "Last window" moved to `SUPER + SHIFT + grave`.
- **`gnome-control-center` is launched with `XDG_CURRENT_DESKTOP=GNOME`**, - **`gnome-control-center` is launched with `XDG_CURRENT_DESKTOP=GNOME`**,
+226
View File
@@ -0,0 +1,226 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Named actions
--
-- The one thing that makes it safe for settings.json to describe a shortcut.
--
-- Panama Settings lets a person invent a keyboard shortcut and assign a
-- four-finger gesture. Both are stored in the same user-editable JSON file the
-- rest of the desktop reads, and both have to end up as something the
-- compositor executes -- which is exactly the shape of every configuration
-- format that turned out to be a shell injection.
--
-- It is not one here, and this file is why. A stored action is DATA:
--
-- { kind = "app" | "shell" | "window", target = "<id>", label = "<text>" }
--
-- `kind` is an enum with three members. `target` is either a key of one of the
-- whitelist tables below -- whose values are literals written here, in Lua, by
-- a human -- or, for `app`, an identifier that has to match a character class
-- containing no shell metacharacter at all, and which is then quoted as a
-- single argv element for panama-launch rather than pasted into a command.
--
-- So the worst a hand-edited (or maliciously written) settings file can do is
-- pick a different entry from a list that is fixed at ship time, or launch an
-- application by id. It cannot introduce a command. There is no path from a
-- stored string to a new exec string; the table lookups are the only source of
-- one.
--
-- Everything invalid returns nil and the caller skips the bind or gesture --
-- the prefs.lua philosophy: never raise, never guess. A malformed entry costs
-- one shortcut, never the keymap and never the compositor.
--
-- Required by keybinds.lua (custom shortcuts) and input.lua (four-finger
-- gestures). services/Keybinds.qml's describeAction() is the QML mirror of the
-- vocabulary below; the two lists have to be edited together.
-- ─────────────────────────────────────────────────────────────────────────────
local actions = {}
-- ── Shell verbs ─────────────────────────────────────────────────────────────
--
-- Every entry is a command string written HERE. Nothing stored anywhere else
-- contributes a character to one; `target` only chooses which of these to use.
--
-- The `qs ipc call` targets and functions are the ones quickshell/shell.qml
-- actually registers -- an IpcHandler silently declines to register a function
-- it cannot type-check, so a verb invented here would be a shortcut that does
-- nothing. Checked against shell.qml, not remembered.
--
-- The three that are not IPC (`launcher`, `color-picker`, `lock`) are the same
-- literal commands the shipped binds in keybinds.lua use, for the same reason
-- they use them: they are the tools, not the shell.
local SHELL = {
["dnd-toggle"] = { label = "Do Not Disturb", command = "qs ipc call notifications dnd" },
["notifications"] = { label = "Notifications", command = "qs ipc call notifications toggle" },
["overview"] = { label = "Overview", command = "qs ipc call overview toggle" },
["launcher"] = { label = "Launcher", command = "vicinae toggle" },
["clipboard"] = { label = "Clipboard history", command = "qs ipc call clipboard toggle" },
["screenshot"] = { label = "Screenshot / record", command = "qs ipc call capture open" },
["screenshot-screen"] = { label = "Screenshot: whole screen", command = "qs ipc call capture screenNow" },
["screenshot-window"] = { label = "Screenshot: window", command = "qs ipc call capture windowNow" },
["screen-intelligence"]= { label = "Screen Intelligence", command = "qs ipc call screen-intelligence open" },
["color-picker"] = { label = "Color picker", command = "hyprpicker -a -f hex" },
["quick-settings"] = { label = "Quick settings", command = "qs ipc call quicksettings toggle" },
["settings"] = { label = "Settings", command = "qs ipc call settings toggle" },
["cheatsheet"] = { label = "Keyboard shortcuts", command = "qs ipc call cheatsheet toggle" },
["focus-session"] = { label = "Focus session", command = "qs ipc call focus reveal" },
["caffeine"] = { label = "Keep awake", command = "qs ipc call caffeine toggle" },
["night-light"] = { label = "Night Light", command = "qs ipc call night-light toggle" },
["activity"] = { label = "Activity", command = "qs ipc call activity toggle" },
["power-menu"] = { label = "Power menu", command = "qs ipc call powermenu toggle" },
["lock"] = { label = "Lock", command = "loginctl lock-session" },
}
-- ── Window verbs ────────────────────────────────────────────────────────────
--
-- Builders rather than dispatchers, so nothing is constructed for a verb that
-- is never chosen, and so a gesture builds its dispatcher when the fingers
-- move rather than holding one from config time.
--
-- `workspace:N` is not in the table: it is ten entries that differ by a number,
-- and the number is validated as 1..10 in `window_action` below.
local WINDOW = {
["float-toggle"] = { label = "Toggle float", build = function() return hl.dsp.window.float({ action = "toggle" }) end },
["fullscreen"] = { label = "Fullscreen", build = function() return hl.dsp.window.fullscreen({ mode = "fullscreen" }) end },
["pin"] = { label = "Pin window", build = function() return hl.dsp.window.pin({ action = "toggle" }) end },
}
-- Published so a contract can read the vocabulary without parsing this file,
-- and so the ten workspace verbs have one definition rather than two.
actions.shell_verbs = SHELL
actions.window_verbs = WINDOW
actions.workspace_min = 1
actions.workspace_max = 10
-- ── Targets ─────────────────────────────────────────────────────────────────
-- Letters, digits, and the four punctuation marks a desktop id actually uses.
-- Deliberately excludes every shell metacharacter, quote, slash and space, so
-- an id that passes cannot change the meaning of a command line even before it
-- is quoted -- the quoting below is the second lock on the same door.
local APP_TARGET = "^[A-Za-z0-9@._%-]+$"
local function valid_app_target(target)
return type(target) == "string"
and #target >= 1 and #target <= 128
and target:match(APP_TARGET) ~= nil
end
-- Single-quoted for the shell, with the one escape single quotes need. Same
-- function keybinds.lua uses for the go-to patterns, and used here for the
-- same reason: the value reaches the command as one argument.
local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
-- Google RE2 metacharacters, escaped so the id matches itself literally.
-- panama-launch takes a regular expression, and an unescaped "org.gnome.Files"
-- would also match "orgxgnomexFiles".
local function escape_regex(value)
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
end
-- ── Resolution ──────────────────────────────────────────────────────────────
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
-- The launch-or-focus path the shipped application keys use: raise the window
-- if it is already open, start it if it is not. The id is the class pattern
-- (anchored, escaped) and the thing to start; on Wayland an application's
-- desktop id and its window class are the same string often enough that this
-- is the right first guess, and the wrong guess costs a second window rather
-- than an error.
--
-- gtk-launch activates a desktop entry by id, which is what the applications
-- catalog in Settings offers -- a desktop id is not a binary and cannot be
-- exec'd directly.
local function app_action(target)
if not valid_app_target(target) then
return nil
end
local launch_command = table.concat({
launcher_bin,
"--class", shell_quote("^" .. escape_regex(target) .. "$"),
"--", "gtk-launch", shell_quote(target),
}, " ")
return function() return hl.dsp.exec_cmd(launch_command) end
end
local function shell_action(target)
if type(target) ~= "string" then
return nil
end
local verb = SHELL[target]
if verb == nil then
return nil
end
return function() return hl.dsp.exec_cmd(verb.command) end
end
local function window_action(target)
if type(target) ~= "string" then
return nil
end
local verb = WINDOW[target]
if verb ~= nil then
return verb.build
end
local index = target:match("^workspace:(%d+)$")
if index == nil then
return nil
end
local number = tonumber(index)
if number == nil or number < actions.workspace_min or number > actions.workspace_max then
return nil
end
return function() return hl.dsp.focus({ workspace = number }) end
end
local KINDS = {
app = app_action,
shell = shell_action,
window = window_action,
}
-- The builder for one stored entry, or nil when the entry is anything this
-- file does not recognise. Everything above funnels through here, so there is
-- exactly one place where a stored value becomes an action.
local function builder(entry)
if type(entry) ~= "table" then
return nil
end
local resolve = KINDS[entry.kind]
if resolve == nil then
return nil
end
return resolve(entry.target)
end
-- For hl.bind: the dispatcher itself, or nil.
function actions.dispatcher(entry)
local build = builder(entry)
if build == nil then
return nil
end
return build()
end
-- For hl.gesture: a function, which is what a gesture action has to be when it
-- is not one of Hyprland's own built-in names ("workspace" and friends).
function actions.gesture(entry)
local build = builder(entry)
if build == nil then
return nil
end
return function() hl.dispatch(build()) end
end
-- True when an entry resolves to something. Cheap enough to call twice; used
-- where the caller wants to check before it commits to emitting anything.
function actions.valid(entry)
return builder(entry) ~= nil
end
return actions
+74 -16
View File
@@ -23,12 +23,46 @@ hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland") hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP=Hyprland")
hl.exec_cmd("systemctl --user start hyprland-session.target") hl.exec_cmd("systemctl --user start hyprland-session.target")
-- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. -- Units: polkit prompts, wallpaper, launcher daemon, idle/lock. All four
hl.exec_cmd("systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service") -- carry `ConditionEnvironment=WAYLAND_DISPLAY`, and hl.exec_cmd fires
-- commands without waiting for them to finish, so the dbus-update call
-- above racing this one is not safe to assume complete -- a lost race
-- leaves the Condition unmet and the unit silently never starts (exit 0,
-- no error). hypridle is the only listener for the logind Lock signal,
-- so that failure mode is "lock-session goes to nobody". Re-import
-- synchronously in the same shell invocation first so the Condition
-- always sees it, regardless of how the dbus-update call above scheduled.
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
-- into its binary and cannot be themed. Only one agent may register per
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
-- prompt if Panama's ever fails to come up.
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
-- Pending repairs for this machine, if any. Oneshot: it checks, tells the
-- user when there is something to tell, and exits. Started here rather
-- than enabled so it belongs to the Hyprland session; see the unit.
hl.exec_cmd("systemctl --user start panama-migrate-notify.service")
-- Notices when a program dumps core and says so once per session. Under
-- GNOME, ABRT does this; here nothing did, and applications died silently.
hl.exec_cmd("systemctl --user start panama-crash-watch.service")
-- Text expansion. change-settings runs `espanso service register`, which
-- writes and enables espanso's own user unit; the explicit start makes the
-- first Hyprland login after a fresh install work rather than the second.
-- Started on its own line: unlike the units above it carries no
-- ConditionEnvironment, and a missing unit (espanso not yet registered)
-- must not muddy the start of the four that lock and wallpaper depend on.
hl.exec_cmd("systemctl --user start espanso.service")
-- The shell: bar, dock, overview, quick settings, notifications, capture. -- The shell: bar, dock, overview, quick settings, notifications, capture.
-- No systemd unit ships with quickshell, so it runs as a compositor child. -- A supervised unit rather than a compositor child, for the one property
hl.exec_cmd("quickshell --daemonize") -- a child cannot have: if the shell crashes, systemd restarts it in two
-- seconds instead of leaving a desktop with no bar until someone knows
-- what to type. The unit ships in config/local/share/systemd/user and is
-- started per-session here like every other Panama unit.
hl.exec_cmd("systemctl --user start panama-quickshell.service")
-- Removable-media automounting. GNOME did this invisibly via gvfs+udisks; -- Removable-media automounting. GNOME did this invisibly via gvfs+udisks;
-- outside GNOME something has to ask udisks to mount. No tray icon: the -- outside GNOME something has to ask udisks to mount. No tray icon: the
@@ -39,22 +73,46 @@ hl.on("hyprland.start", function()
-- Keyring unlock, for Nextcloud and Bitwarden credential storage. -- Keyring unlock, for Nextcloud and Bitwarden credential storage.
hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11") hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=secrets,ssh,pkcs11")
-- Tray applications carried over from ~/.config/autostart. These need the -- Nextcloud and Bitwarden are NOT started here. Both ship a
-- Quickshell tray (an SNI host) to be up, hence starting after it. -- ~/.config/autostart/*.desktop entry, and systemd's own
-- systemd-xdg-autostart-generator turns every such entry into a
-- `PartOf=graphical-session.target` unit (`[email protected]`,
-- `[email protected]`) that fires once the uwsm
-- session brings up graphical-session.target -- confirmed live via
-- `systemctl --user list-units 'app-*@autostart.service'`. An explicit
-- second launch here used to duplicate that: for Bitwarden specifically,
-- each `flatpak run` gets its own sandbox instance, so the two starts
-- didn't just race, they left two competing processes fighting over the
-- app's single-instance lock, with neither reliably owning a usable
-- window. Trust the generator instead of re-launching.
-- --
-- Commands are copied verbatim from the GNOME .desktop files rather than -- RustDesk is deliberately absent too: it ships an enabled *system*
-- guessed: Bitwarden is a flatpak with no `bitwarden` binary on PATH, and -- service (`rustdesk --service`) that spawns --server and --tray for the
-- its autostart entry launches a specific script with --autostart. -- session on its own. Starting it here as well would give you two trays.
--
-- RustDesk is deliberately absent: it ships an enabled *system* service
-- (`rustdesk --service`) that spawns --server and --tray for the session on
-- its own. Starting it here as well would give you two trays.
hl.exec_cmd("nextcloud --background")
hl.exec_cmd("flatpak run --command=bitwarden.sh com.bitwarden.desktop --autostart")
end) end)
-- Tear down what the start handler brought up.
--
-- This stopped `hyprland-session.target`, which does not exist on Fedora --
-- systemctl reports "Unit not found" and the handler achieves nothing. The
-- units started above are not children of the compositor, so nothing else
-- stopped them either: hyprpaper, hypridle, vicinae and the polkit agent kept
-- running after logout, and each one Requires=graphical-session.target, so
-- that target stayed active with no session behind it.
--
-- The next login then failed. Both uwsm and gnome-session refuse to start into
-- a session that is already running ("A compositor or graphical-session*
-- target is already active!"), so "Hyprland (uwsm-managed)" and GNOME both
-- bounced straight back to the login screen while plain Hyprland -- which
-- makes no such check -- kept working. The desktop appeared to be the only one
-- that functioned, when it was the one leaving the mess.
--
-- Stopping graphical-session.target is enough on its own: every unit above is
-- PartOf= it, so they come down with it, and it carries StopWhenUnneeded=yes.
-- It is stopped rather than the units listed individually so that a unit added
-- to the start handler later cannot be forgotten here.
hl.on("hyprland.shutdown", function() hl.on("hyprland.shutdown", function()
hl.exec_cmd("systemctl --user stop hyprland-session.target") hl.exec_cmd("systemctl --user stop graphical-session.target")
end) end)
return true return true
+2 -2
View File
@@ -1,7 +1,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
-- Environment -- Environment
-- --
-- hl.env() sets variables before the display server initialises, which is what -- hl.env() sets variables before the display server initializes, which is what
-- makes it valid for AQ_* and cursor variables. -- makes it valid for AQ_* and cursor variables.
-- --
-- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm -- NOTE for uwsm sessions: if you log in via "Hyprland (uwsm-managed)", uwsm
@@ -58,7 +58,7 @@ hl.env("QT_QPA_PLATFORM", "wayland;xcb")
-- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3 -- "gtk3" makes Qt derive its palette from the live GTK3 theme via the qgtk3
-- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than -- platform plugin, so Qt apps match adw-gtk3-dark exactly rather than
-- approximating it -- and it gives them GTK file/font/colour dialogs. It also -- approximating it -- and it gives them GTK file/font/color dialogs. It also
-- covers Qt5 and Qt6 with one value. -- covers Qt5 and Qt6 with one value.
-- --
-- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses -- Note this is a single value, NOT a fallback list: Qt splits on ":" and uses
+1 -1
View File
@@ -1,5 +1,5 @@
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# hypridle — idle behaviour # hypridle — idle behavior
# #
# Mirrors the GNOME power profile this replaces, exactly: # Mirrors the GNOME power profile this replaces, exactly:
# org.gnome.desktop.session idle-delay = 300 (screen off at 5min) # org.gnome.desktop.session idle-delay = 300 (screen off at 5min)
+1 -1
View File
@@ -15,7 +15,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
-- Shared preferences first: looks/input/monitors read their defaults through it. -- Shared preferences first: looks/input/monitors read their defaults through it.
-- It never raises, so a missing or malformed settings file costs customisations -- It never raises, so a missing or malformed settings file costs customizations
-- and nothing else. -- and nothing else.
require("prefs") require("prefs")
+2 -2
View File
@@ -3,9 +3,9 @@
# #
# GENERATED FILE. Edit hyprlock.conf.template and re-run # GENERATED FILE. Edit hyprlock.conf.template and re-run
# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the # quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
# next colour scheme change. # next color scheme change.
# #
# The colours here follow the desktop's light/dark setting. They used to be # The colors here follow the desktop's light/dark setting. They used to be
# hardcoded Tokyo Night Moon, which meant the one screen you see most often # hardcoded Tokyo Night Moon, which meant the one screen you see most often
# stayed dark when everything else went light. # stayed dark when everything else went light.
# #
+4 -8
View File
@@ -17,20 +17,16 @@
# frame time for nothing. # frame time for nothing.
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# One block for every output: per-monitor wallpaper is applied at runtime by
# the shell (services/Wallpaper.qml) over IPC, so a per-connector block here
# would just be one machine's connector name in everyone's config.
wallpaper { wallpaper {
monitor = DP-2 monitor =
path = ~/Pictures/Wallpapers/faroe_islands.jpg path = ~/Pictures/Wallpapers/faroe_islands.jpg
# cover = fill the output, cropping overflow. Matches the "zoom" picture # cover = fill the output, cropping overflow. Matches the "zoom" picture
# option this setup used under GNOME. # option this setup used under GNOME.
fit_mode = cover fit_mode = cover
} }
# Fallback for any output without its own block above.
wallpaper {
monitor =
path = ~/Pictures/Wallpapers/faroe_islands.jpg
fit_mode = cover
}
ipc = on ipc = on
splash = false splash = false
+1 -1
View File
@@ -9,7 +9,7 @@
# hyprland-guiutils hyprland-dialog / hyprland-run # hyprland-guiutils hyprland-dialog / hyprland-run
# hyprpolkitagent authentication prompts (via hyprland-qt-support) # hyprpolkitagent authentication prompts (via hyprland-qt-support)
# #
# Colours are Tokyo Night Moon, matching quickshell/config/Theme.qml and # Colors are Tokyo Night Moon, matching quickshell/config/Theme.qml and
# hypr/looks.lua. Format is hyprlang; path is fixed at # hypr/looks.lua. Format is hyprlang; path is fixed at
# $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit). # $XDG_CONFIG_HOME/hypr/hyprtoolkit.conf (confirmed in libhyprtoolkit).
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
+131 -6
View File
@@ -6,6 +6,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs") local prefs = require("prefs")
local actions = require("actions")
hl.config({ hl.config({
input = { input = {
@@ -22,10 +23,10 @@ hl.config({
repeat_rate = prefs.get("keyRepeatRate", 33), repeat_rate = prefs.get("keyRepeatRate", 33),
-- 1 = FOLLOW. The window under the pointer takes focus. This comment -- 1 = FOLLOW. The window under the pointer takes focus. This comment
-- previously claimed 1 was "click to focus, GNOME's behaviour", which -- previously claimed 1 was "click to focus, GNOME's behavior", which
-- is the opposite of what Hyprland does -- `hyprctl descriptions` gives -- is the opposite of what Hyprland does -- `hyprctl descriptions` gives
-- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}], -- map: [{"separate":3},{"detached":2},{"follow":1},{"disabled":0}],
-- so click-to-focus is 0. Changing the shipped value is a behaviour -- so click-to-focus is 0. Changing the shipped value is a behavior
-- decision rather than a correction, so the value is left alone and -- decision rather than a correction, so the value is left alone and
-- only the description is fixed; Settings exposes all four. -- only the description is fixed; Settings exposes all four.
follow_mouse = prefs.getInt("followMouse", 1), follow_mouse = prefs.getInt("followMouse", 1),
@@ -35,16 +36,140 @@ hl.config({
-- Still focus-follows-pointer, just less twitchy. -- Still focus-follows-pointer, just less twitchy.
mouse_refocus = false, mouse_refocus = false,
-- Flat pointer response, no acceleration. Matters for gaming. -- 0 = NEXT, the compositor's own default: focus goes to the next window
-- in the layout order. `hyprctl descriptions` publishes
-- map: [{"mru":2},{"cursor":1},{"next":0}], and Settings offers all
-- three; 0 is repeated here so nothing changes on a machine with no
-- settings file.
focus_on_close = prefs.getInt("focusOnClose", 0),
-- Flat pointer response by default, no acceleration. Matters for
-- gaming; Settings offers adaptive for people who want it back.
sensitivity = prefs.get("pointerSensitivity", 0), sensitivity = prefs.get("pointerSensitivity", 0),
accel_profile = "flat", accel_profile = prefs.get("accelProfile", "flat"),
natural_scroll = prefs.get("naturalScroll", false),
scroll_factor = prefs.get("scrollFactor", 1.0),
left_handed = prefs.get("leftHanded", false),
-- Empty is a value, not an omission: it is what `hyprctl getoption`
-- reports as "[[EMPTY]]" before anything writes the option, and it means
-- "let libinput pick per device" -- two fingers on a touchpad, the wheel
-- on a mouse. Writing it back explicitly is the same branch a stock
-- Hyprland takes, and it keeps the setting reversible: without an empty
-- choice in the schema there would be no way back from a scroll method
-- once one was picked. Settings offers 2fg / edge / on_button_down /
-- no_scroll alongside it, the four words the option's own description
-- names (it publishes no map).
scroll_method = prefs.get("scrollMethod", ""),
-- Only consulted while scroll_method is on_button_down. 0 means the
-- device's own middle button.
scroll_button = prefs.get("scrollButton", 0),
-- Clicking a floating window raises and focuses it. -- Clicking a floating window raises and focuses it.
float_switch_override_focus = 2, float_switch_override_focus = 2,
-- Every touchpad preference Settings offers is read here as well as
-- applied live: the live half (hyprctl eval) reaches a running
-- compositor, and THIS half is what survives `hyprctl reload` and the
-- gap before the shell starts. A schema `hypr:` entry without a
-- prefs.get() here is a control that quietly reverts -- the whole
-- Mouse & Touchpad page once had exactly that bug. Defaults must
-- match PreferenceSchema's; the hypr-prefs-contract checks both.
touchpad = {
tap_to_click = prefs.get("touchpadTapToClick", true),
natural_scroll = prefs.get("touchpadNaturalScroll", true),
disable_while_typing = prefs.get("touchpadDisableWhileTyping", true),
scroll_factor = prefs.get("touchpadScrollFactor", 1.0),
drag_lock = prefs.getInt("touchpadDragLock", 0),
middle_button_emulation = prefs.get("touchpadMiddleButtonEmulation", false),
clickfinger_behavior = prefs.get("touchpadClickfinger", false),
-- Underscores here, hyphens in the option name getoption answers to
-- (input:touchpad:tap-and-drag) -- the same split tap_to_click has
-- at the top of this table. Hyprland's own default is true; it is
-- repeated rather than omitted so the schema, the Lua, and the
-- compositor all state the same value.
tap_and_drag = prefs.get("touchpadTapAndDrag", true),
},
},
-- Pointer BEHAVIOUR, as opposed to pointer appearance: how the cursor reacts
-- to typing and to workspace switches. The cursor's looks -- theme, size,
-- hardware cursors, the inactivity fade -- are a separate cursor table in
-- looks.lua. hl.config calls are additive per option, so the two tables
-- coexist; they are split by what a person would go looking for, and these
-- two appear on Settings' Mouse & Touchpad page rather than in Appearance.
cursor = {
hide_on_key_press = prefs.get("cursorHideWhileTyping", false),
-- An integer with three states, written from a switch: disable = 0,
-- enable = 1, force = 2. Settings offers the first two, so getInt is
-- what bridges a stored boolean to the number Hyprland wants -- the same
-- pairing render.cm_auto_hdr uses in looks.lua.
warp_on_change_workspace = prefs.getInt("cursorWarpOnWorkspaceChange", 0),
},
-- Tuning for the three-finger gestures registered below.
gestures = {
workspace_swipe_distance = prefs.getInt("swipeDistance", 300),
workspace_swipe_invert = prefs.get("swipeInvert", true),
}, },
}) })
-- Desktop machine: no touchpad, no gestures worth wiring. If a laptop ever -- ── Touchpad gestures ───────────────────────────────────────────────────────
-- runs this config, add touchpad settings in overrides.lua. --
-- GNOME's gestures, reproduced: three fingers sideways moves between
-- workspaces, three fingers up opens the overview, three fingers down closes
-- it. That is the same muscle memory the keybinds were built to preserve.
--
-- Registered unconditionally rather than behind a preference. Hyprland 0.56
-- dropped `gestures:workspace_swipe` in favour of this `gesture` keyword, and a
-- registration is read at config time -- so a toggle would need a reload to
-- take effect, which is worse than the nothing these cost on a machine with no
-- touchpad. What IS tunable at runtime lives in Settings: how far a swipe has
-- to travel, and which way round it goes.
--
-- open and close rather than toggle twice: with a toggle on both directions,
-- swiping up from an already-open overview would close it, and swiping down
-- would reopen it. GNOME does not do that, and neither does this.
local overview = function(fn)
return function() hl.exec_cmd("qs ipc call overview " .. fn) end
end
hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" })
hl.gesture({ fingers = 3, direction = "up", action = overview("open") })
hl.gesture({ fingers = 3, direction = "down", action = overview("close") })
-- ── Four-finger gestures ────────────────────────────────────────────────────
--
-- The three above are the desktop's, fixed. These four are the user's: each
-- holds a named action from settings.json, or {} for unassigned, and the
-- vocabulary is exactly the one custom shortcuts use -- actions.lua resolves
-- both, through the same whitelist tables, so a gesture can no more introduce
-- a command than a keybind can.
--
-- Nothing is emitted for an unassigned direction. That matters more here than
-- it looks: a registration is read at config time and there is no way to
-- remove one afterwards, so emitting a no-op gesture for every direction would
-- consume the four-finger swipes permanently, including for whatever the
-- compositor might do with them later.
--
-- Four rather than three because three is spoken for, and because four fingers
-- is the largest number of them a touchpad this size can tell apart.
local custom_gestures = {
{ pref = "gestureFourUp", direction = "up" },
{ pref = "gestureFourDown", direction = "down" },
{ pref = "gestureFourLeft", direction = "left" },
{ pref = "gestureFourRight", direction = "right" },
}
for _, gesture in ipairs(custom_gestures) do
local action = actions.gesture(prefs.get(gesture.pref, {}))
if action ~= nil then
hl.gesture({ fingers = 4, direction = gesture.direction, action = action })
end
end
return true return true
+351 -21
View File
@@ -12,6 +12,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs") local prefs = require("prefs")
local actions = require("actions")
local mod = "SUPER" local mod = "SUPER"
@@ -34,6 +35,14 @@ local osd = function(action)
return "$HOME/.config/quickshell/scripts/panama-osd " .. action return "$HOME/.config/quickshell/scripts/panama-osd " .. action
end end
local lid = function(action)
return "$HOME/.config/quickshell/scripts/panama-lid " .. action
end
local dictate = function(action)
return "$HOME/.config/quickshell/scripts/panama-dictate " .. action
end
-- Quickshell IPC targets. See quickshell/shell.qml for the handlers. -- Quickshell IPC targets. See quickshell/shell.qml for the handlers.
local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end local qs = function(target, fn) return "qs ipc call " .. target .. " " .. fn end
@@ -66,24 +75,137 @@ local function valid_chord(chord)
return chord:match("^[%w_+%s:]+$") ~= nil return chord:match("^[%w_+%s:]+$") ~= nil
end end
-- ── Categories ──────────────────────────────────────────────────────────────
-- What a bind is FOR, as opposed to what it does.
--
-- The cheatsheet groups by this, and the Shortcuts settings page uses it too.
-- It is recorded here rather than guessed from the description, which is what
-- Keybinds.qml used to do: matching substrings put "Close window" and "Close
-- the notification list" in the same group and left anything phrased unusually
-- in whichever bucket matched first.
--
-- The sections of this file already ARE the categories, so a section sets one
-- and every bind below it inherits it. That keeps the annotation to one line
-- per section instead of one per bind, and makes the grouping impossible to
-- forget: a new bind lands in the category of the section it was written in.
local categories = {}
local current_category = "Other"
local function category(name)
current_category = name
end
local function bind(chord, action, opts) local function bind(chord, action, opts)
local override = overrides[chord] local override = overrides[chord]
if valid_chord(override) then if valid_chord(override) then
chord = override chord = override
end end
-- Keyed by the chord actually bound, so the shell can join on what
-- hyprctl reports without having to know about overrides.
categories[chord] = current_category
return hl.bind(chord, action, opts) return hl.bind(chord, action, opts)
end end
-- Written where the shell can read it. Hyprland reports a Lua bind's
-- dispatcher as `__lua` with a bytecode offset, so there is no way to attach
-- anything to a bind that survives into `hyprctl binds` -- the manifest is
-- how this side of the desktop tells the other what these binds are for.
--
-- Never raises. A read-only or missing state directory costs the categories,
-- which the shell falls back from, and must never cost the keymap.
local function write_categories()
local state_home = os.getenv("XDG_STATE_HOME")
if state_home == nil or state_home == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return
end
state_home = home .. "/.local/state"
end
local parts = {}
for chord, name in pairs(categories) do
-- Chords and category names are both from this file, so the only
-- escaping that can matter is the quote character itself.
parts[#parts + 1] = string.format('%q:%q', chord, name)
end
table.sort(parts)
local path = state_home .. "/panama/keybind-categories.json"
os.execute("mkdir -p " .. string.format("%q", state_home .. "/panama"))
local file = io.open(path, "w")
if file == nil then
return
end
file:write("{" .. table.concat(parts, ",") .. "}\n")
file:close()
end
-- SUPER opens a new one. SUPER+ALT goes to the one you already have.
--
-- That order matters and was chosen deliberately after trying the reverse.
-- Making the plain key focus an existing window reads well in a demo and is
-- what macOS does, but it makes "give me another terminal" the awkward case --
-- and on a tiling desktop, opening a second terminal beside the first is not
-- an edge case, it is the normal way to work. So the plain key keeps doing
-- what it has always done, and the modifier is the new capability rather than
-- a tax on the old one.
--
-- 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 a worse trade than borrowing a modifier.
--
-- The go-to binds still launch when nothing is open. A key that silently does
-- nothing is worse than one that does the obvious thing.
--
-- Patterns are regular expressions and are anchored. An unanchored "mail"
-- would match gmail-notifier, and the go-to-mail key would raise somebody's
-- notifier instead. Single-quoted for the shell so a backslash reaches the
-- matcher rather than being eaten on the way.
local function shell_quote(value)
return "'" .. value:gsub("'", "'\\''") .. "'"
end
local launcher_bin = "$HOME/.local/share/Panama/bin/panama-launch"
local function go_to(class, command, title)
local parts = { launcher_bin, "--class", shell_quote(class) }
if title then
parts[#parts + 1] = "--title"
parts[#parts + 1] = shell_quote(title)
end
parts[#parts + 1] = "--"
parts[#parts + 1] = command
return table.concat(parts, " ")
end
category("Applications")
bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" }) bind(mod .. " + T", hl.dsp.exec_cmd(terminal), { description = "Terminal" })
bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" }) bind(mod .. " + N", hl.dsp.exec_cmd(editor), { description = "Neovim" })
bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" }) bind(mod .. " + W", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" }) bind(mod .. " + F", hl.dsp.exec_cmd(files), { description = "Files" })
bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) bind(mod .. " + C", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" }) bind(mod .. " + E", hl.dsp.exec_cmd(mail), { description = "Mail" })
-- Go to the one already open, or start it if there is none.
bind(mod .. " + ALT + T", hl.dsp.exec_cmd(go_to("^kitty$", terminal)),
{ description = "Go to terminal" })
bind(mod .. " + ALT + N", hl.dsp.exec_cmd(go_to("^kitty$", editor, "nvim")),
{ description = "Go to Neovim" })
bind(mod .. " + ALT + W", hl.dsp.exec_cmd(go_to("^helium", browser)),
{ description = "Go to browser" })
bind(mod .. " + ALT + F", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Nautilus$", files)),
{ description = "Go to files" })
bind(mod .. " + ALT + C", hl.dsp.exec_cmd(go_to("^org\\.gnome\\.Calculator$", calculator)),
{ description = "Go to calculator" })
bind(mod .. " + ALT + E", hl.dsp.exec_cmd(go_to("^org\\.mozilla\\.thunderbird", mail)),
{ description = "Go to mail" })
bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" }) bind(mod .. " + I", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" }) bind("CTRL + SHIFT + Escape", hl.dsp.exec_cmd(sysmonitor), { description = "System monitor" })
-- ── Launcher ──────────────────────────────────────────────────────────────── -- ── Launcher ────────────────────────────────────────────────────────────────
category("Applications")
-- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were -- All three keys open the same launcher, on purpose: SUPER+A and SUPER+R were
-- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a -- the GNOME app-grid and run-dialog shortcuts, and SUPER+SPACE is here as a
-- third option to settle on. Vicinae covers apps, calculator, files, clipboard, -- third option to settle on. Vicinae covers apps, calculator, files, clipboard,
@@ -97,6 +219,10 @@ bind(mod .. " + Space", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- application without dropping to a TTY. Depends on nothing but wofi itself. -- application without dropping to a TTY. Depends on nothing but wofi itself.
bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" }) bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("wofi"), { description = "Fallback launcher" })
-- Every shortcut, on one key. Slash because "what are the keys" is a question,
-- and because it is the one punctuation key no other bind wants.
bind(mod .. " + slash", hl.dsp.exec_cmd(qs("cheatsheet", "toggle")), { description = "Keyboard shortcuts" })
-- Clipboard history and emoji, straight into the relevant launcher view. -- Clipboard history and emoji, straight into the relevant launcher view.
-- Deeplink form is the one from vicinae's own Hyprland quickstart. -- Deeplink form is the one from vicinae's own Hyprland quickstart.
bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"), bind(mod .. " + V", hl.dsp.exec_cmd("vicinae vicinae://launch/clipboard/history"),
@@ -105,6 +231,7 @@ bind(mod .. " + Period", hl.dsp.exec_cmd("vicinae vicinae://launch/emoji/search"
{ description = "Emoji picker" }) { description = "Emoji picker" })
-- ── Shell surfaces (Quickshell) ───────────────────────────────────────────── -- ── Shell surfaces (Quickshell) ─────────────────────────────────────────────
category("Shell")
-- SUPER+S was GNOME's quick settings; kept. -- SUPER+S was GNOME's quick settings; kept.
bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" }) bind(mod .. " + S", hl.dsp.exec_cmd(qs("quicksettings", "toggle")), { description = "Quick settings" })
@@ -118,7 +245,7 @@ bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd(qs("focus", "reveal")), { descriptio
-- which Hyprland has no equivalent for. -- which Hyprland has no equivalent for.
bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" }) bind(mod .. " + grave", hl.dsp.exec_cmd(qs("overview", "toggle")), { description = "Overview" })
-- Notification centre. -- Notification center.
bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" }) bind(mod .. " + B", hl.dsp.exec_cmd(qs("notifications", "toggle")), { description = "Notifications" })
-- Screenshot / screen record. One key, then pick screen / window / region and -- Screenshot / screen record. One key, then pick screen / window / region and
@@ -133,10 +260,49 @@ bind("ALT + Print", hl.dsp.exec_cmd(qs("capture", "windowNow")), { description =
bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")), bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
{ description = "Screen Intelligence" }) { description = "Screen Intelligence" })
-- Colour picker: copies the hex under the cursor to the clipboard. -- Color picker: copies the hex under the cursor to the clipboard.
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Colour picker" }) bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
-- ── Magnifier ───────────────────────────────────────────────────────────────
category("Shell")
--
-- The chords are NOT the obvious SUPER+=/-/0. SUPER+equal is already "Reset
-- split" (Window management, below), and taking a daily tiling key away to
-- give the magnifier the prettiest chord on the keyboard is the wrong trade.
--
-- SUPER+ALT is where they went instead, which is also where GNOME's magnifier
-- lives: gsettings' magnifier-zoom-in / magnifier-zoom-out ship as
-- <Alt><Super>= and <Alt><Super>-, so this is the shortcut the machine this
-- desktop replaced already had. SUPER+ALT+0 -- free; the workspace digits are
-- plain ALT -- resets to 1.00 ×, reading as "back to zero magnification".
--
-- These go THROUGH the shell rather than calling `hyprctl keyword
-- cursor:zoom_factor` directly. Setting the compositor option behind Panama's
-- back would leave the stored preference and the Magnifier slider claiming a
-- magnification that is not the one on screen; the IPC call commits through
-- the same verified-preference path the slider uses, so the store, the
-- compositor and the settings page can never disagree. It also posts the OSD,
-- which is the only way to see what the factor now is with the pointer
-- somewhere else entirely.
--
-- Not `repeating`: the step is multiplicative (×1.25), so a held key repeating
-- at the keyboard rate would arrive at the 5.00 × ceiling in about a tenth of
-- a second. One press, one step.
--
-- Written as literal chords rather than `mod .. " + ALT + ..."` (as
-- "SUPER + Backspace" already is, above) because these three are the most
-- collision-prone binds in the file -- they were placed around one -- and a
-- literal is the form both the duplicate-chord check and the settings page's
-- chord display can actually read.
bind("SUPER + ALT + equal", hl.dsp.exec_cmd(qs("accessibility", "zoom in")),
{ description = "Zoom in" })
bind("SUPER + ALT + minus", hl.dsp.exec_cmd(qs("accessibility", "zoom out")),
{ description = "Zoom out" })
bind("SUPER + ALT + 0", hl.dsp.exec_cmd(qs("accessibility", "zoom reset")),
{ description = "Reset zoom" })
-- ── Window management ─────────────────────────────────────────────────────── -- ── Window management ───────────────────────────────────────────────────────
category("Windows")
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" }) bind(mod .. " + U", hl.dsp.window.fullscreen({ mode = "fullscreen" }), { description = "Fullscreen" })
@@ -180,20 +346,30 @@ bind(mod .. " + CTRL + L", hl.dsp.window.swap({ direction = "r" }), { descriptio
-- Resize (Forge: window-resize-<edge>-<increase|decrease>). -- Resize (Forge: window-resize-<edge>-<increase|decrease>).
-- --
-- Forge resized one named edge at a time. Hyprland resizes the active window -- Forge resized one named EDGE at a time: its resize() grows the window for a
-- along an axis and lets the layout decide which edge actually moves, so the -- positive amount in every direction, and the edge only decides which side
-- eight Forge keys collapse onto four behaviours. The pairing is kept -- moves -- Y grew leftward, O grew rightward, and so on. Hyprland resizes along
-- consistent with the original: Y/B/O/M are horizontal, I/P/U/N are vertical, -- an axis and lets the layout choose the border, so those eight distinct
-- and "increase" always grows while "decrease" always shrinks. -- behaviours collapse onto four and the direction is simply not expressible.
--
-- Because of that the sizes are deliberately INVERTED from Forge's naming.
-- Carried over faithfully, "increase" grew and "decrease" shrank, which was
-- correct on paper and wrong under the fingers: with the edge gone, the keys
-- that used to pull a window open from one side now push it from the other.
-- Gabriel uses these daily and reads Y/O as shrink and B/M as grow, so that is
-- what they do. Faithfulness to a mapping nobody can feel is not worth much.
local step = 60 local step = 60
bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + Y", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" }) bind(mod .. " + SHIFT + O", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" })
bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + B", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = -step, y = 0, relative = true }), { repeating = true, description = "Narrower" }) bind(mod .. " + SHIFT + M", hl.dsp.window.resize({ x = step, y = 0, relative = true }), { repeating = true, description = "Wider" })
bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) bind(mod .. " + SHIFT + I", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" }) bind(mod .. " + SHIFT + U", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" })
bind(mod .. " + SHIFT + P", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) -- SUPER+SHIFT+P was double-bound with the color picker above; moved to
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = -step, relative = true }), { repeating = true, description = "Shorter" }) -- Comma, which continues the bottom-row cluster (B/M/N) this axis already
-- uses rather than landing on an arbitrary free key.
bind(mod .. " + SHIFT + Comma", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
bind(mod .. " + SHIFT + N", hl.dsp.window.resize({ x = 0, y = step, relative = true }), { repeating = true, description = "Taller" })
-- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay -- Window cycling (GNOME: cycle-windows on SUPER+Tab), now with an overlay
-- showing what you are choosing between. -- showing what you are choosing between.
@@ -218,11 +394,23 @@ bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true, description =
bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" }) bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true, description = "Resize window with pointer" })
-- ── Workspaces ────────────────────────────────────────────────────────────── -- ── Workspaces ──────────────────────────────────────────────────────────────
category("Workspaces")
-- ALT is the workspace modifier, matching the GNOME setup. -- ALT is the workspace modifier, matching the GNOME setup.
-- --
-- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces: -- Plain relative selectors ("+1" / "-1") reproduce GNOME's dynamic workspaces:
-- moving right past the last workspace creates a new one, and moving left from -- moving right past the last workspace creates a new one, and moving left from
-- the first clamps instead of wrapping. -- the first clamps instead of wrapping.
-- Behavior for the relative/cyclic binds below. These are Hyprland's own
-- `binds:` options -- not part of general/dwindle -- and have no other home
-- in the config, so they are read here rather than in looks.lua.
hl.config({
binds = {
workspace_back_and_forth = prefs.get("workspaceBackAndForth", false),
allow_workspace_cycles = prefs.get("allowWorkspaceCycles", false),
},
})
bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) bind("ALT + H", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) bind("ALT + L", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" }) bind("ALT + SHIFT + H", hl.dsp.window.move({ workspace = "-1" }), { description = "Move window to workspace left" })
@@ -245,26 +433,29 @@ end
bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" }) bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "+1" }), { description = "Workspace right" })
bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" }) bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "-1" }), { description = "Workspace left" })
-- Minimise, as far as Hyprland has one. -- Minimize, as far as Hyprland has one.
-- --
-- Hyprland has no minimise: it receives the request (the binary has -- Hyprland has no minimize: it receives the request (the binary has
-- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes -- setSetMinimized handlers for xdg, XWayland and foreign-toplevel) but exposes
-- no dispatcher, no config option and not even an event to hook, so titlebar -- no dispatcher, no config option and not even an event to hook, so titlebar
-- minimise buttons are inert and cannot be made to work. A tiling WM has no -- minimize buttons are inert and cannot be made to work. A tiling WM has no
-- iconified state and no taskbar to restore from. -- iconified state and no taskbar to restore from.
-- --
-- The scratchpad is the honest equivalent: the window goes away, and the same -- The scratchpad is the honest equivalent: the window goes away, and the same
-- key brings it back. Bound to X to match the muscle memory it replaces. -- key brings it back. Bound to X to match the muscle memory it replaces.
bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimised)" }) bind(mod .. " + X", hl.dsp.workspace.toggle_special("scratch"), { description = "Toggle scratchpad (restore minimized)" })
bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimise to scratchpad" }) bind(mod .. " + SHIFT + X", hl.dsp.window.move({ workspace = "special:scratch" }), { description = "Minimize to scratchpad" })
-- ── Session ───────────────────────────────────────────────────────────────── -- ── Session ─────────────────────────────────────────────────────────────────
category("Session")
-- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to -- GNOME's lock was SUPER+L, which is "focus right" here, so lock moves to
-- CTRL+ALT+L -- the other binding most people already have in muscle memory. -- CTRL+ALT+L -- the other binding most people already have in muscle memory.
bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" }) bind("CTRL + ALT + L", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
bind("SUPER + Backspace", hl.dsp.exec_cmd("loginctl lock-session"), { description = "Lock" })
bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" }) bind("CTRL + ALT + Delete", hl.dsp.exec_cmd(qs("powermenu", "toggle")), { description = "Power menu" })
-- ── Media and volume ──────────────────────────────────────────────────────── -- ── Media and volume ────────────────────────────────────────────────────────
category("Media & hardware")
-- locked = true keeps these working on the lock screen, as they do in GNOME. -- locked = true keeps these working on the lock screen, as they do in GNOME.
-- 6% steps match the GNOME volume-step setting. -- 6% steps match the GNOME volume-step setting.
bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" }) bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 6")), { locked = true, repeating = true , description = "Volume up" })
@@ -272,6 +463,31 @@ bind("XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 6")), { locked = t
bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" }) bind("XF86AudioMute", hl.dsp.exec_cmd(osd("volume toggle")), { locked = true , description = "Mute" })
bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" }) bind("XF86AudioMicMute", hl.dsp.exec_cmd(osd("microphone toggle")), { locked = true , description = "Mute microphone" })
-- ── Dictation ───────────────────────────────────────────────────────────────
category("Shell")
--
-- Hold to talk, exactly like push-to-talk anywhere else: the mic is open only
-- while the key is down, so it cannot be left listening by forgetting about it.
-- Two binds on one chord, the second flagged `release`.
--
-- No `repeating`: holding a key normally repeats the press, which would restart
-- the recording several times a second. The daemon refuses a second start while
-- one is running, so a repeat would be harmless -- but not asking for it is
-- better than relying on being refused.
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("start")),
{ description = "Dictate (hold to talk)" })
bind(mod .. " + D", hl.dsp.exec_cmd(dictate("stop")),
{ release = true, description = "Dictate (transcribe on release)" })
-- Escape out of a recording without transcribing it. Bound to the same modifier
-- so it can be reached with the dictation key still held.
bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd(dictate("cancel")),
{ description = "Cancel dictation" })
-- Back to media: the dictation binds sit here for historical reasons, and the
-- category has to be set again or everything below inherits theirs.
category("Media & hardware")
-- Fine-grained steps, matching GNOME's shift/alt volume modifiers. -- Fine-grained steps, matching GNOME's shift/alt volume modifiers.
bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" }) bind("SHIFT + XF86AudioRaiseVolume", hl.dsp.exec_cmd(osd("volume up 1")), { locked = true, repeating = true , description = "Volume up (fine)" })
bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" }) bind("SHIFT + XF86AudioLowerVolume", hl.dsp.exec_cmd(osd("volume down 1")), { locked = true, repeating = true , description = "Volume down (fine)" })
@@ -285,6 +501,72 @@ bind("XF86AudioStop", hl.dsp.exec_cmd(osd("media stop")), { locked = true , desc
bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" }) bind("XF86MonBrightnessUp", hl.dsp.exec_cmd(osd("brightness up 5")), { locked = true, repeating = true , description = "Brightness up" })
bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" }) bind("XF86MonBrightnessDown", hl.dsp.exec_cmd(osd("brightness down 5")), { locked = true, repeating = true , description = "Brightness down" })
-- Airplane mode. The Framework's F10 emits exactly this keysym, and for a
-- while it emitted it into silence. panama-osd owns the toggle so the OSD
-- can say which way it went.
bind("XF86RFKill", hl.dsp.exec_cmd(osd("airplane toggle")), { locked = true, description = "Airplane mode" })
-- F9 on the same row. GNOME shows a display-switching OSD here; until
-- mirroring exists (DESKTOP-PARITY gap), the honest action is the page where
-- displays are actually arranged.
bind("XF86Display", hl.dsp.exec_cmd("qs ipc call settings page displays"), { description = "Display settings" })
-- ── The power button ────────────────────────────────────────────────────────
category("Media & hardware")
--
-- logind is told to ignore the power key (config/copy ships the drop-in) so a
-- stray press is a question, not an instant poweroff. That makes what the
-- question IS Panama's to choose, and `powerButtonAction` is where the choice
-- is recorded. Until the next boot after that drop-in lands, logind still acts
-- on the key; this bind costs nothing extra then.
--
-- The branch runs ON EVERY PRESS rather than here at config time.
--
-- A config-time branch would be shorter -- `prefs.get` and four `if`s -- and it
-- would also make this the one control on the Power page that does nothing
-- until the compositor is reloaded. Every other setting in Panama applies as
-- you change it, and a power button that ignores what the settings app says it
-- does is a worse thing to ship than a long command string. So the bind is a
-- `case` over what the settings file says at the moment the key goes down.
--
-- Everything that can go wrong lands on the shipped default: no jq, no file, a
-- truncated file, or a value nobody recognises all fall through to `*)` and
-- open the menu. The failure direction is "the power button opens a menu",
-- never "the power button does something you did not ask for".
--
-- Powering off goes THROUGH the menu with Power Off pre-armed rather than
-- calling `systemctl poweroff` here. The menu's two-press confirm is what
-- stands between a pocketed key and an unsaved afternoon, and a direct
-- poweroff would quietly throw it away -- so a person who picks "Powers off"
-- gets a fast poweroff, not an unguarded one.
local power_button = {
menu = qs("powermenu", "toggle"),
suspend = "systemctl suspend",
poweroff = qs("powermenu", "open") .. " poweroff",
nothing = ":",
}
local power_button_command = table.concat({
[[case "$(jq -r '.powerButtonAction // empty' "${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json" 2>/dev/null)" in]],
"suspend) " .. power_button.suspend .. " ;;",
"poweroff) " .. power_button.poweroff .. " ;;",
"nothing) " .. power_button.nothing .. " ;;",
"*) " .. power_button.menu .. " ;;",
"esac",
}, " ")
bind("XF86PowerOff", hl.dsp.exec_cmd(power_button_command),
{ locked = true, description = "Power button" })
-- The lid, as a switch rather than a key. Closing a docked lid turns the
-- internal panel off so nothing renders inside a closed shell and no
-- workspace strands on an invisible output; opening it turns the panel back
-- on. panama-lid owns the decision -- undocked machines suspend via logind
-- before this matters, and the guard's inhibitor handles staying awake.
bind("switch:on:Lid Switch", hl.dsp.exec_cmd(lid("close")), { locked = true, description = "Lid closed" })
bind("switch:off:Lid Switch", hl.dsp.exec_cmd(lid("open")), { locked = true, description = "Lid opened" })
category("Applications")
-- Hardware keys GNOME mapped that have obvious equivalents. -- Hardware keys GNOME mapped that have obvious equivalents.
bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" }) bind("XF86Tools", hl.dsp.exec_cmd(settings), { description = "Settings" })
bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" }) bind("XF86Calculator", hl.dsp.exec_cmd(calculator), { description = "Calculator" })
@@ -293,4 +575,52 @@ bind("XF86WWW", hl.dsp.exec_cmd(browser), { description = "Browser" })
bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" }) bind("XF86Mail", hl.dsp.exec_cmd(mail), { description = "Mail" })
bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" }) bind("XF86Search", hl.dsp.exec_cmd(launcher), { description = "Launcher" })
-- ── Custom shortcuts ────────────────────────────────────────────────────────
--
-- Shortcuts the user invented, from `customBinds` in settings.json. Each entry
-- is { chord, kind, target, label } -- data, never a command. actions.lua turns
-- the kind/target pair into a dispatcher through whitelist tables; an entry it
-- does not recognise resolves to nil and is silently not emitted.
--
-- Emitted LAST, and through hl.bind rather than the `bind` wrapper above. Two
-- separate reasons, both about keeping the two rebinding mechanisms apart:
--
-- * `keybindOverrides` is keyed by a SHIPPED chord. A custom bind has no
-- shipped chord -- it is rebound by rewriting its own entry -- so putting
-- one through `bind` would let an override for some shipped key silently
-- move a custom one that happened to share a chord.
-- * last means a custom chord that collides with a shipped one loses, which
-- is checked explicitly below rather than left to Hyprland's ordering.
--
-- Settings prevents a collision upstream; this is the second lock, because the
-- file is hand-editable and losing a shipped key to a typo is not acceptable.
category("Custom")
local function custom_bind(chord, dispatcher, label)
categories[chord] = current_category
return hl.bind(chord, dispatcher, { description = label })
end
for _, entry in ipairs(prefs.get("customBinds", {})) do
if type(entry) == "table" then
local chord = entry.chord
local label = entry.label
-- A description is not decoration: keybinds-contract fails a build
-- with a description-less bind, and the cheatsheet and Shortcuts page
-- both list what they find. A nameless shortcut is unfindable.
if valid_chord(chord)
and type(label) == "string" and label ~= ""
and categories[chord] == nil
then
local dispatcher = actions.dispatcher(entry)
if dispatcher ~= nil then
custom_bind(chord, dispatcher, label)
end
end
end
end
write_categories()
return true return true
+118 -14
View File
@@ -1,8 +1,8 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
-- Look and feel -- Tokyo Night Moon -- Look and feel -- Tokyo Night Moon
-- --
-- Colours here must stay in sync with quickshell/config/Theme.qml. -- Colors here must stay in sync with quickshell/config/Theme.qml.
-- accent #82aaff borders / focus -- accent user-selectable, see `accents` below -- blue (#82aaff) ships
-- bg #222436 base -- bg #222436 base
-- --
-- Performance note: every animation below is event-driven. Nothing uses the -- Performance note: every animation below is event-driven. Nothing uses the
@@ -14,6 +14,76 @@
local prefs = require("prefs") local prefs = require("prefs")
-- The eight accents, from config/palette.json -- the one place they are
-- written down for everything outside QML. They used to be restated here,
-- which made adding a ninth accent a five-file edit; the file that got
-- forgotten was always the one that fails silently.
--
-- Falls back to the shipped blue pair if the palette cannot be read, for the
-- same reason prefs never raises: a missing file costs the accent colour, not
-- the compositor config.
local palette = prefs.readJson(os.getenv("HOME") .. "/.config/quickshell/config/palette.json")
local accents = palette.accents or {
blue = { dark = "82aaff", darkSecondary = "b172b0", light = "2e7de9", lightSecondary = "9854f1" },
}
-- The accent pair a fresh session or `hyprctl reload` starts from.
-- services/ColorScheme.qml overwrites this live, from the same Theme.accents
-- data, once the shell settles (~1.2s after startup -- see its `settle`
-- Timer). This table exists only so the compositor is never observably blue
-- for a non-blue accent during the gap before that first live apply.
local accentScheme = prefs.get("colorScheme", "dark")
local accentPair = accents[prefs.get("accentName", "blue")] or accents.blue
local accentStart = accentScheme == "light" and accentPair.light or accentPair.dark
local accentEnd = accentScheme == "light" and accentPair.lightSecondary or accentPair.darkSecondary
-- ── Color filters ───────────────────────────────────────────────────────────
--
-- Grayscale and three color-blindness corrections, as end-of-pipe screen
-- shaders. Hyprland composites the desktop and then runs one fragment shader
-- over the result, so a filter here covers every window, the shell, the cursor
-- and video alike -- which is the only way a filter is honest.
--
-- The PREFERENCE is the enum, not the path. That split is deliberate: storing
-- the path would put a filesystem location a person can edit into the value
-- that becomes `decoration:screen_shader`, and it would make the stored value
-- disagree with what hyprctl reports back (which is the path), failing the
-- schema shape and write-sweep contracts. So PreferenceSchema's colorFilter
-- entry carries no `hypr:` block, and the enum→path mapping is written twice
-- on purpose: here, for reloads and for the moment before the shell starts,
-- and in services/SystemSettings.qml's applyColorFilter for the live apply.
-- The two lists have to be edited together.
--
-- "none" and anything unrecognised both produce the empty string, which is
-- what Hyprland reads as "no shader" -- and is the value it needs to be given
-- to turn one OFF, since there is no way to unset the option.
local shaderDir = (function()
local configHome = os.getenv("XDG_CONFIG_HOME")
if configHome == nil or configHome == "" then
local home = os.getenv("HOME")
if home == nil or home == "" then
return nil
end
configHome = home .. "/.config"
end
return configHome .. "/hypr/shaders"
end)()
local colorFilters = {
grayscale = "grayscale.frag",
protanopia = "protanopia.frag",
deuteranopia = "deuteranopia.frag",
tritanopia = "tritanopia.frag",
}
local colorFilterShader = ""
do
local file = colorFilters[prefs.get("colorFilter", "none")]
if file ~= nil and shaderDir ~= nil then
colorFilterShader = shaderDir .. "/" .. file
end
end
hl.config({ hl.config({
general = { general = {
gaps_in = prefs.get("gapsIn", 5), gaps_in = prefs.get("gapsIn", 5),
@@ -22,12 +92,14 @@ hl.config({
border_size = prefs.get("borderSize", 2), border_size = prefs.get("borderSize", 2),
col = { col = {
-- The focused accent role: blue leads, orchid follows, on a -- The focused accent role, on a diagonal so the pair is visible
-- diagonal so the pair is visible on both a tall and a wide -- on both a tall and a wide window. Driven by the chosen
-- window. ColorScheme never writes this role; a future accent -- accentName (see the `accents` table above); services/
-- picker can own it without fighting light/dark mode. -- ColorScheme.qml applies the same values live, and restates them
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, -- from Theme.accent/Theme.accentSecondary on every scheme change
-- The neutral inactive role follows the colour scheme because a -- too, since each accent carries a separate pair per scheme.
active_border = { colors = { "rgba(" .. accentStart .. "ee)", "rgba(" .. accentEnd .. "ee)" }, angle = 115 },
-- The neutral inactive role follows the color scheme because a
-- dark neutral disappears against a light desktop. -- dark neutral disappears against a light desktop.
-- services/ColorScheme.qml applies the same values live; this is -- services/ColorScheme.qml applies the same values live; this is
-- the value a fresh session starts from. -- the value a fresh session starts from.
@@ -43,10 +115,10 @@ hl.config({
-- Harmless on its own; tearing only happens where a rule opts in. -- Harmless on its own; tearing only happens where a rule opts in.
allow_tearing = true, allow_tearing = true,
layout = "dwindle", layout = prefs.get("windowLayout", "dwindle"),
snap = { snap = {
enabled = true, enabled = prefs.get("windowSnapping", true),
window_gap = prefs.getInt("snapWindowGap", 10), window_gap = prefs.getInt("snapWindowGap", 10),
monitor_gap = prefs.getInt("snapMonitorGap", 10), monitor_gap = prefs.getInt("snapMonitorGap", 10),
respect_gaps = prefs.get("snapRespectGaps", false), respect_gaps = prefs.get("snapRespectGaps", false),
@@ -63,6 +135,10 @@ hl.config({
fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0), fullscreen_opacity = prefs.get("fullscreenOpacity", 1.0),
inactive_opacity = prefs.get("inactiveOpacity", 1.0), inactive_opacity = prefs.get("inactiveOpacity", 1.0),
-- Accessibility: dim every window but the focused one.
dim_inactive = prefs.get("dimInactive", false),
dim_strength = prefs.get("dimStrength", 0.5),
blur = { blur = {
enabled = prefs.get("blurEnabled", true), enabled = prefs.get("blurEnabled", true),
size = prefs.get("blurSize", 8), size = prefs.get("blurSize", 8),
@@ -104,16 +180,23 @@ hl.config({
-- New in 0.56. Kept deliberately faint: in this direction the gradient -- New in 0.56. Kept deliberately faint: in this direction the gradient
-- border is the signature, and a strong halo would compete with it. -- border is the signature, and a strong halo would compete with it.
-- This is just enough to lift the focused window off the wallpaper. -- This is just enough to lift the focused window off the wallpaper.
-- Derived from the same accent as active_border above, not hardcoded,
-- so the halo never disagrees with the border it surrounds.
glow = { glow = {
enabled = prefs.get("glowEnabled", true), enabled = prefs.get("glowEnabled", true),
range = prefs.get("glowRange", 8), range = prefs.get("glowRange", 8),
render_power = 2, render_power = 2,
color = "rgba(82aaff33)", color = "rgba(" .. accentStart .. "33)",
color_inactive = "rgba(00000000)", color_inactive = "rgba(00000000)",
}, },
-- Off: costs real frame time and reads as smeary on a 60Hz panel. -- Off: costs real frame time and reads as smeary on a 60Hz panel.
motion_blur = { enabled = false }, motion_blur = { enabled = false },
-- The accessibility color filter, resolved above. Empty when off, and
-- empty costs nothing: Hyprland skips the pass entirely rather than
-- running an identity shader.
screen_shader = colorFilterShader,
}, },
animations = { enabled = prefs.get("animationsEnabled", true) }, animations = { enabled = prefs.get("animationsEnabled", true) },
@@ -123,6 +206,7 @@ hl.config({
-- to how the Forge extension behaved on GNOME. -- to how the Forge extension behaved on GNOME.
preserve_split = prefs.get("preserveSplit", true), preserve_split = prefs.get("preserveSplit", true),
smart_resizing = true, smart_resizing = true,
force_split = prefs.getInt("forceSplit", 0),
}, },
-- Only in effect when the tiling layout is "master". Panama ships dwindle, -- Only in effect when the tiling layout is "master". Panama ships dwindle,
@@ -165,12 +249,28 @@ hl.config({
-- Let a crashed lock screen be recovered rather than stranding you. -- Let a crashed lock screen be recovered rather than stranding you.
allow_session_lock_restore = true, allow_session_lock_restore = true,
-- Don't let apps steal focus by shouting; matches GNOME's behaviour. -- Don't let apps steal focus by shouting; matches GNOME's behavior.
focus_on_activate = false, focus_on_activate = prefs.get("focusOnActivate", false),
-- Whether pointing at another monitor is enough to move focus there,
-- or it takes a click. GNOME moves on pointer; both are offered.
mouse_move_focuses_monitor = prefs.get("mouseMoveFocusesMonitor", true),
-- Window swallowing: a terminal hides itself while a graphical
-- application launched from it is open, and comes back when that
-- application exits. Off by default -- it is a real change in how the
-- desktop behaves, and one that is confusing rather than broken if you
-- did not ask for it: your terminal appears to vanish.
--
-- The regex is narrow on purpose. Anything matching it can swallow, so
-- a permissive pattern means windows disappearing in cases nobody
-- intended. Only the two terminals this desktop actually ships.
enable_swallow = prefs.get("windowSwallow", false),
swallow_regex = "^(kitty|com\\.mitchellh\\.ghostty)$",
}, },
render = { render = {
-- Colour management is stable and on by default in 0.56. -- Color management is stable and on by default in 0.56.
cm_enabled = true, cm_enabled = true,
-- 1 = automatically flip the monitor into HDR for fullscreen content -- 1 = automatically flip the monitor into HDR for fullscreen content
@@ -198,6 +298,10 @@ hl.config({
-- Fade the cursor out after 4s of no movement, like GNOME does. -- Fade the cursor out after 4s of no movement, like GNOME does.
inactive_timeout = prefs.get("cursorInactiveTimeout", 4), inactive_timeout = prefs.get("cursorInactiveTimeout", 4),
-- The accessibility magnifier. 1.0 = off.
zoom_factor = prefs.get("magnifierFactor", 1.0),
zoom_rigid = prefs.get("magnifierRigid", false),
}, },
ecosystem = { ecosystem = {
+179 -39
View File
@@ -1,7 +1,7 @@
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
-- Monitors -- Monitors
-- --
-- Kuycon P20 on DP-2: 4500x3000 @ 60Hz, 1.5x fractional scale. -- Kuycon P20 (matched by description): 4500x3000 @ 60Hz, 1.5x fractional scale.
-- 4500/1.5 = 3000 and 3000/1.5 = 2000, both integers, so this is a "clean" -- 4500/1.5 = 3000 and 3000/1.5 = 2000, both integers, so this is a "clean"
-- fractional scale and Hyprland will not complain. -- fractional scale and Hyprland will not complain.
-- --
@@ -13,8 +13,9 @@
-- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to -- than an HDR desktop, the desktop runs SDR at 10-bit and HDR is handed to
-- fullscreen games only, via render.cm_auto_hdr in looks.lua. -- fullscreen games only, via render.cm_auto_hdr in looks.lua.
-- --
-- To try full-time HDR anyway, set cm = "hdr" below (or override it in -- To try full-time HDR anyway, set shipped_cm = "hdr" below (or pick HDR for
-- overrides.lua) and read the notes in that file first. -- this display in Settings, which saves it as the display's colorProfile and
-- wins over the shipped value) and read the notes in overrides.lua first.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs") local prefs = require("prefs")
@@ -23,12 +24,21 @@ local prefs = require("prefs")
-- { ["DP-2"] = { -- { ["DP-2"] = {
-- mode = "3840x2160@60", scale = 2, transform = 0, -- mode = "3840x2160@60", scale = 2, transform = 0,
-- x = 0, y = 0, primary = true, -- x = 0, y = 0, primary = true,
-- vrrMode = -1, colorProfile = "auto", bitdepth = 10,
-- sdrBrightness = 1, sdrSaturation = 1, mirrorOf = "",
-- } } -- } }
-- --
-- Only mode, scale, and transform are read. Colour management and bit depth -- The fields past `primary` are optional: records written before they existed
-- stay here, because those are the settings with a documented reason attached -- carry none of them, and the shipped values below stand instead. That is the
-- (see the header) rather than preferences, and a settings page has no way to -- relationship in both directions -- what is written here is what a saved
-- explain the screencopy tradeoff at the moment you would be changing it. -- record inherits, and what Settings saves is what overrides it, so the two
-- stop fighting over the same monitor rule.
--
-- Geometry and colour fail differently on purpose. A half-written position is
-- refused outright (below), because guessing one can strand an output where
-- nothing can reach it. An unreadable colour, VRR or mirror value is dropped
-- on its own and the shipped default stands: the worst it costs is a wrong
-- shade, and taking the whole record down with it would cost the arrangement.
local displays = prefs.get("displays", {}) local displays = prefs.get("displays", {})
if type(displays) ~= "table" then if type(displays) ~= "table" then
displays = {} displays = {}
@@ -125,44 +135,131 @@ local function display_position(entry, fallback)
return fallback return fallback
end end
local color_profiles = { auto = true, srgb = true, wide = true, hdr = true }
local function color_profile(entry, fallback)
local value = entry ~= nil and entry.colorProfile or nil
if type(value) == "string" and color_profiles[value] then
return value
end
return fallback
end
local function bitdepth_value(entry, fallback)
local value = entry ~= nil and entry.bitdepth or nil
if value == 8 or value == 10 then
return value
end
return fallback
end
-- -1 means the display follows the global misc.vrr policy, which is said by
-- leaving the key out. 3 is the global policy's own value and not a per-display
-- choice, so it is not accepted here either.
local function vrr_value(entry)
local value = entry ~= nil and entry.vrrMode or nil
if type(value) ~= "number" or value ~= math.floor(value)
or value < 0 or value > 2 then
return nil
end
return value
end
-- Neutral is 1.0, and the neutral value is left out rather than written: a rule
-- that names it pins the display to it, which is not the same as leaving the
-- trim alone.
local function sdr_value(value, minimum, maximum)
if type(value) ~= "number" or value ~= value
or value < minimum or value > maximum
or math.abs(value - 1) < 0.001 then
return nil
end
return value
end
-- A mirror needs a target that is not itself and not another mirror -- Hyprland
-- has no chain to follow -- and the primary may not mirror at all, since the
-- arrangement is anchored on it.
local function mirror_value(entry, output)
local value = entry ~= nil and entry.mirrorOf or nil
if type(value) ~= "string" or value == "" or value == output
or value:match("^[%w_.-]+$") == nil
or entry.primary == true then
return nil
end
local target = displays[value]
if type(target) == "table" and type(target.mirrorOf) == "string"
and target.mirrorOf ~= "" then
return nil
end
return value
end
-- Colour, VRR and mirroring layered onto a rule that already carries
-- mode/position/scale/transform. A monitor rule replaces the previous rule for
-- that output whole, so the shipped defaults are passed in here rather than
-- written in a rule of their own. `connector` is the output name the record was
-- saved under, which is not always the rule's own output: the Kuycon rule
-- matches by description.
local function with_display_fields(rule, entry, connector, default_bitdepth, default_cm)
rule.bitdepth = bitdepth_value(entry, default_bitdepth)
rule.cm = color_profile(entry, default_cm)
rule.vrr = vrr_value(entry)
rule.sdrbrightness = entry ~= nil and sdr_value(entry.sdrBrightness, 0.8, 2.0) or nil
rule.sdrsaturation = entry ~= nil and sdr_value(entry.sdrSaturation, 0.8, 1.2) or nil
-- A mirror shows its target's picture in its target's place, so the saved
-- position is not the compositor's to honour or ours to ask for.
local mirror = mirror_value(entry, connector)
if mirror ~= nil then
rule.mirror = mirror
rule.position = "auto"
end
return rule
end
-- Every connected output uses the same validated per-output store. Automatic
-- placement and the compositor's normal color policy unless the entry says
-- otherwise.
for output, _ in pairs(displays) do
local entry = display_entry(output)
if entry ~= nil then
hl.monitor(with_display_fields({
output = output,
mode = entry.mode,
position = display_position(entry, "auto"),
scale = entry.scale,
transform = entry.transform,
}, entry, output, nil, nil))
end
end
-- The Kuycon P20, matched by what it is rather than where it is plugged in.
-- This used to be a rule for connector DP-2 outright, which handed the panel's
-- 4500x3000 mode and 1.5 scale to whatever monitor a stranger's machine had on
-- its most common DisplayPort connector. Emitted after the prefs loop so a
-- saved entry for its connector still carries the mode/scale/position, while
-- this rule holds the shipped defaults and the panel-specific color policy.
local shipped_mode = "4500x3000@60" local shipped_mode = "4500x3000@60"
local shipped_scale = 1.5 local shipped_scale = 1.5
local shipped_transform = 0 local shipped_transform = 0
local dp2 = display_entry("DP-2")
hl.monitor({ -- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of DP 1.4
output = "DP-2", -- HBR3, so this relies on DSC. If the display fails to light up or falls back to
mode = dp2 and dp2.mode or shipped_mode, -- a lower mode, drop this to 8 first.
position = display_position(dp2, "0x0"), local shipped_bitdepth = 10
scale = dp2 and dp2.scale or shipped_scale,
transform = dp2 and dp2.transform or shipped_transform,
-- 10-bit output. 4500x3000@60 at 10bpc is ~24 Gbps, right at the edge of -- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header.
-- DP 1.4 HBR3, so this relies on DSC. If the display fails to light up or local shipped_cm = "auto"
-- falls back to a lower mode, drop this line first.
bitdepth = 10,
-- "auto" = sRGB at 8bpc, wide gamut at 10bpc. Not HDR; see header. local kuycon = display_entry("DP-2")
cm = "auto", hl.monitor(with_display_fields({
}) output = "desc:GVT Kuycon P20",
mode = kuycon and kuycon.mode or shipped_mode,
-- Other connected outputs use the same validated per-output store. They keep position = display_position(kuycon, "0x0"),
-- automatic placement and the compositor's normal colour policy; DP-2 alone scale = kuycon and kuycon.scale or shipped_scale,
-- carries the panel-specific 10-bit policy documented above. transform = kuycon and kuycon.transform or shipped_transform,
for output, _ in pairs(displays) do }, kuycon, "DP-2", shipped_bitdepth, shipped_cm))
if output ~= "DP-2" then
local entry = display_entry(output)
if entry ~= nil then
hl.monitor({
output = output,
mode = entry.mode,
position = display_position(entry, "auto"),
scale = entry.scale,
transform = entry.transform,
})
end
end
end
-- Any monitor not named above: sane defaults rather than nothing. -- Any monitor not named above: sane defaults rather than nothing.
hl.monitor({ hl.monitor({
@@ -172,4 +269,47 @@ hl.monitor({
scale = "auto", scale = "auto",
}) })
-- ── Workspaces on the primary display only ──────────────────────────────────
--
-- GNOME offered one workspace choice worth reproducing: whether the other
-- screens join in. Off, every monitor has its own workspaces and switching
-- affects whichever one has focus -- Hyprland's own behaviour, so it needs no
-- rules at all. On, workspaces 1-10 are pinned to the primary display and a
-- second screen keeps a workspace of its own that stays put.
--
-- Ten because that is how many the keybinds reach: ALT+1 through ALT+0 in
-- keybinds.lua. Binding more would pin workspaces nothing can navigate to, and
-- binding fewer would leave the last few behaving differently from the rest for
-- no reason a person could see.
--
-- The rules are emitted here rather than written live because Hyprland reads
-- them at config time and offers no way to remove one afterwards: writing an
-- empty monitor leaves the previous binding in place. So the config is the only
-- honest source, and applying a change is a reload.
if prefs.get("workspacesOnPrimaryOnly", false) == true then
-- Only a record display_entry accepts counts. A half-written entry is one
-- the monitor rules above already refuse, so pinning ten workspaces to it on
-- the strength of a `primary` flag nothing else trusts would put them on a
-- screen that never got a rule of its own.
local primaries = {}
for output, _ in pairs(displays) do
local entry = display_entry(output)
if entry ~= nil and entry.primary == true then
primaries[#primaries + 1] = output
end
end
-- Without a primary there is nothing to pin to, and guessing one would move
-- every workspace onto whichever screen happened to sort first. Two records
-- both claiming primary is the same problem wearing a different hat: pairs()
-- has no order, so picking one of them would pin the workspaces to a
-- different screen from one reload to the next. Neither case guesses.
if #primaries == 1 then
local primary = primaries[1]
for i = 1, 10 do
hl.workspace_rule({ workspace = tostring(i), monitor = primary })
end
end
end
return true return true
+28 -1
View File
@@ -16,7 +16,7 @@
-- cannot drift apart. -- cannot drift apart.
-- --
-- Nothing here may raise. A missing, empty, truncated, or actively malformed -- Nothing here may raise. A missing, empty, truncated, or actively malformed
-- file must cost the user nothing worse than their customisations; it must -- file must cost the user nothing worse than their customizations; it must
-- never cost them a working compositor. Every failure path returns the caller's -- never cost them a working compositor. Every failure path returns the caller's
-- fallback. -- fallback.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
@@ -244,6 +244,33 @@ function prefs.getInt(key, fallback)
return math.floor(value + 0.5) return math.floor(value + 0.5)
end end
-- Read and decode any JSON file, using the same never-raise parser the
-- settings store uses. Returns an empty table for a file that is missing,
-- empty, or malformed, so a caller can index the result without checking.
--
-- Exists so config/palette.json can be read by looks.lua rather than the eight
-- accents being written out a second time in Lua. A bad palette costs the
-- accent colours, never the compositor config.
function prefs.readJson(path)
if type(path) ~= "string" or path == "" then
return {}
end
local file = io.open(path, "r")
if not file then
return {}
end
local text = file:read("*a")
file:close()
if not text or text:match("^%s*$") then
return {}
end
local ok, parsed = pcall(decode, text)
if not ok or type(parsed) ~= "table" then
return {}
end
return parsed
end
-- True when a settings file was actually read. Useful from overrides.lua. -- True when a settings file was actually read. Useful from overrides.lua.
function prefs.loaded() function prefs.loaded()
return next(values) ~= nil return next(values) ~= nil
+112 -2
View File
@@ -9,6 +9,8 @@
-- to invert it. -- to invert it.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── Upstream sanity rules ─────────────────────────────────────────────────── -- ── Upstream sanity rules ───────────────────────────────────────────────────
hl.window_rule({ hl.window_rule({
name = "suppress-maximize-events", name = "suppress-maximize-events",
@@ -69,7 +71,7 @@ hl.window_rule({
float = true, float = true,
}) })
-- Settings-style utility windows and pickers: float and centre, like GNOME did. -- Settings-style utility windows and pickers: float and center, like GNOME did.
hl.window_rule({ hl.window_rule({
match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" }, match = { class = "^(pavucontrol|org\\.pulseaudio\\.pavucontrol|nm-connection-editor|blueman-manager|org\\.gnome\\.Settings)$" },
float = true, float = true,
@@ -77,6 +79,18 @@ hl.window_rule({
center = true, center = true,
}) })
-- Quick Look. The GNOME previewer is what the file manager opens on space,
-- and it is an overlay rather than a window someone manages: tiled, it shoves
-- the file manager aside and has to be dismissed before the list is usable
-- again. Sized generously because a preview that needs zooming is not a
-- preview; it still gets a margin so the file underneath stays visible.
hl.window_rule({
match = { class = "^org\\.gnome\\.NautilusPreviewer$" },
float = true,
size = { "monitor_w * 0.7", "monitor_h * 0.8" },
center = true,
})
-- Portal dialogs (file chooser, screen share picker) should always float. -- Portal dialogs (file chooser, screen share picker) should always float.
hl.window_rule({ hl.window_rule({
match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" }, match = { class = "^(xdg-desktop-portal-gtk|org\\.freedesktop\\.impl\\.portal\\.desktop\\.gtk|hyprland-share-picker)$" },
@@ -124,6 +138,97 @@ hl.window_rule({
no_dim = true, no_dim = true,
}) })
-- ── Per-application rules the user wrote ────────────────────────────────────
--
-- `windowRules` in settings.json, edited from Settings' Windows page. Each
-- entry is data and nothing else:
--
-- { class, label, float, center, size = {w, h}, workspace, noAnim, game,
-- noDim, pin }
--
-- `class` is matched LITERALLY. Hyprland matches with RE2, so a class typed
-- into a text field is a regular expression unless something escapes it -- and
-- "org.gnome.Files" as a pattern also matches "orgxgnomexFiles", while a
-- half-typed "(" is a pattern error rather than a rule that matches nothing.
-- Escaped and anchored here, so what the user typed is what gets matched.
--
-- Emitted AFTER the shipped rules and deliberately WITHOUT a name. Hyprland
-- evaluates every named rule before every anonymous one, so a named user rule
-- would silently outrank the anonymous shipped rules above it -- the opposite
-- of the intended precedence. Anonymous, last, is what "the user's rule wins"
-- actually means here.
--
-- An entry that fails any check is skipped whole rather than emitted with the
-- bad field dropped: a rule that half-applies is harder to understand than one
-- that is not there, and the settings page can see the same thing is wrong.
local function escape_regex(value)
return (value:gsub("[%^%$%(%)%%%.%[%]%*%+%-%?%{%}%|\\]", "\\%0"))
end
-- The shell's own surfaces are layers, not windows -- but Quickshell's helper
-- windows are not, and a rule that floats or moves one of them would be a user
-- breaking their own desktop from the Windows page. Refused at both ends; this
-- is the end that matters, because the file is hand-editable.
local function reserved_class(class)
local lowered = class:lower()
return lowered:match("^quickshell") ~= nil or lowered:match("^qs%-") ~= nil
end
local function positive_integer(value, low, high)
if type(value) ~= "number" or value ~= math.floor(value) then
return nil
end
if value < low or value > high then
return nil
end
return value
end
for _, entry in ipairs(prefs.get("windowRules", {})) do
if type(entry) == "table" and type(entry.class) == "string" then
local class = entry.class
local rule = nil
if #class >= 1 and #class <= 128 and not reserved_class(class) then
rule = { match = { class = "^" .. escape_regex(class) .. "$" } }
if entry.float == true then rule.float = true end
if entry.center == true then rule.center = true end
if entry.noAnim == true then rule.no_anim = true end
if entry.noDim == true then rule.no_dim = true end
if entry.pin == true then rule.pin = true end
-- The keystone the gaming rules above use: misc.vrr,
-- render.direct_scanout and cursor.no_break_fs_vrr all key off it.
if entry.game == true then rule.content = "game" end
if entry.size ~= nil then
local size = entry.size
local width = type(size) == "table" and positive_integer(size[1], 50, 10000) or nil
local height = type(size) == "table" and positive_integer(size[2], 50, 10000) or nil
if width == nil or height == nil then
rule = nil
else
rule.size = { width, height }
end
end
if rule ~= nil and entry.workspace ~= nil then
local workspace = positive_integer(entry.workspace, 1, 10)
if workspace == nil then
rule = nil
else
rule.workspace = workspace
end
end
end
if rule ~= nil then
hl.window_rule(rule)
end
end
end
-- ── Workspace rules ───────────────────────────────────────────────────────── -- ── Workspace rules ─────────────────────────────────────────────────────────
-- Deliberately NO "smart gaps". -- Deliberately NO "smart gaps".
-- --
@@ -147,10 +252,15 @@ hl.layer_rule({
name = "qs-dock", name = "qs-dock",
match = { namespace = "^qs-dock$" }, match = { namespace = "^qs-dock$" },
blur = true, blur = true,
-- The dock's context menu and its window previews are xdg-popups of this
-- surface, not layers of their own, so they are only blurred if the rule
-- says to blur the popups too. Without it they are flat panes over the
-- wallpaper while everything else on the desktop is glass.
blur_popups = true,
ignore_alpha = 0.3, ignore_alpha = 0.3,
}) })
-- Popovers: calendar, quick settings, notification centre, tray menus. -- Popovers: calendar, quick settings, notification center, tray menus.
hl.layer_rule({ hl.layer_rule({
name = "qs-popover", name = "qs-popover",
match = { namespace = "^qs-popover" }, match = { namespace = "^qs-popover" },
+28
View File
@@ -0,0 +1,28 @@
// Deuteranopia -- green-blind.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// Same family of matrices as protanopia.frag, weighted for the missing green
// cone instead of the red one. See that file for why this is a correction
// rather than a simulation, and for the column-major note.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
const mat3 deuteranopia = mat3(
0.625, 0.700, 0.000,
0.375, 0.300, 0.300,
0.000, 0.000, 0.700
);
void main() {
vec4 pixColor = texture(tex, v_texcoord);
fragColor = vec4(clamp(deuteranopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
}
+28
View File
@@ -0,0 +1,28 @@
// Grayscale.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// Hyprland runs one fragment shader over the finished frame, so this covers
// every window, the shell, video and the cursor alike.
//
// Rec. 709 luminance weights -- the same ones an SVG <feColorMatrix
// type="saturate" values="0"> uses. A flat average would make reds and blues
// far too bright and greens far too dark, because the eye does not weigh the
// channels equally.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
void main() {
vec4 pixColor = texture(tex, v_texcoord);
float luminance = dot(pixColor.rgb, vec3(0.2126, 0.7152, 0.0722));
fragColor = vec4(vec3(luminance), pixColor.a);
}
+33
View File
@@ -0,0 +1,33 @@
// Protanopia -- red-blind.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// The matrix is the feColorMatrix set the mock uses, which is the widely
// carried HCIRN-derived one: it redistributes the red channel into the two the
// eye can still separate, so a red/green pair that was one colour becomes two
// distinguishable ones. It is a CORRECTION, not a simulation -- the point is to
// make the screen readable, not to show what protanopia looks like.
//
// Row-major here, column-major to GLSL: mat3 takes its arguments column by
// column, so the transpose below is the matrix as written in the SVG.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
const mat3 protanopia = mat3(
0.567, 0.558, 0.000,
0.433, 0.442, 0.242,
0.000, 0.000, 0.758
);
void main() {
vec4 pixColor = texture(tex, v_texcoord);
fragColor = vec4(clamp(protanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
}
+28
View File
@@ -0,0 +1,28 @@
// Tritanopia -- blue-blind.
//
// Panama's accessibility color filters. Selected by the `colorFilter`
// preference; hypr/looks.lua maps the enum to this path at config time and
// services/SystemSettings.qml does the same live.
//
// Same family of matrices as protanopia.frag, weighted for the missing blue
// cone. See that file for why this is a correction rather than a simulation,
// and for the column-major note.
#version 300 es
precision mediump float;
in vec2 v_texcoord;
layout(location = 0) out vec4 fragColor;
uniform sampler2D tex;
const mat3 tritanopia = mat3(
0.950, 0.000, 0.000,
0.050, 0.433, 0.475,
0.000, 0.567, 0.525
);
void main() {
vec4 pixColor = texture(tex, v_texcoord);
fragColor = vec4(clamp(tritanopia * pixColor.rgb, 0.0, 1.0), pixColor.a);
}
+6 -3
View File
@@ -1,8 +1,11 @@
# Tokyo Night Moon — the dark theme Panama ships. # Tokyo Night Moon — the dark theme Panama ships.
# #
# Extracted from kitty.conf so the two schemes can be swapped. kitty.conf # Reference, not the source of the running colours. kitty.conf includes
# includes current-theme.conf, which Panama generates from the colour scheme # current-theme.conf, which panama-theme-apps RENDERS from the active theme's
# setting; that generated file is gitignored because it is machine state. # palette -- so any of the ten themes reaches the terminal, not only the two
# Tokyo Nights. This file used to be copied into that one; it is kept because
# it is a complete kitty theme somebody may want to include directly, and
# because it is what Moon is supposed to look like.
# #
# Live changes go through `kitty @ set-colors`, which is why kitty.conf enables # Live changes go through `kitty @ set-colors`, which is why kitty.conf enables
# remote control. Without it a scheme change would only reach terminals opened # remote control. Without it a scheme change would only reach terminals opened
+1 -1
View File
@@ -6,7 +6,7 @@
-- Hyprland config read. -- Hyprland config read.
-- --
-- Nothing here may raise. A missing or malformed settings file must cost the -- Nothing here may raise. A missing or malformed settings file must cost the
-- user their colour scheme preference and nothing else; editing text is more -- user their color scheme preference and nothing else; editing text is more
-- important than matching the desktop. -- important than matching the desktop.
local M = {} local M = {}
+3 -3
View File
@@ -1,12 +1,12 @@
-- Tokyo Night, following the desktop's colour scheme. -- Tokyo Night, following the desktop's color scheme.
-- --
-- Moon when Panama is dark, Day when it is light. Same theme family either way, -- Moon when Panama is dark, Day when it is light. Same theme family either way,
-- so the editor keeps the identity the rest of the desktop has rather than -- so the editor keeps the identity the rest of the desktop has rather than
-- becoming a different-looking application when the scheme flips. -- becoming a different-looking application when the scheme flips.
-- --
-- The readability fixes below are deliberately dark-only. They were written -- The readability fixes below are deliberately dark-only. They were written
-- against Moon's palette -- a pale comment colour, a mid-grey gutter -- and -- against Moon's palette -- a pale comment color, a mid-gray gutter -- and
-- applying them to Day would put light grey text on a light background, which -- applying them to Day would put light gray text on a light background, which
-- is exactly the legibility problem they exist to solve, inverted. -- is exactly the legibility problem they exist to solve, inverted.
local panama = require("config.panama") local panama = require("config.panama")
+1 -1
View File
@@ -22,7 +22,7 @@ console_title_template = '{{ .Shell }} in {{ .Folder }}'
[[blocks.segments]] [[blocks.segments]]
type = 'git' type = 'git'
style = 'plain' style = 'plain'
foreground = 'p:grey' foreground = 'p:gray'
background = 'transparent' background = 'transparent'
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>' template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Runs after `panama migrate` has applied repairs, and only when it applied at
# least one. Takes no arguments.
#
# Useful when a machine keeps local state that a repair might have invalidated.
#
# Copy to ~/.config/panama/hooks/post-migrate and make it executable.
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Runs at the end of ./install, after every stage and after migrations.
#
# Takes no arguments. This is where per-machine setup goes that Panama should
# not carry for everyone: a work laptop's VPN client, a private repository
# somebody clones, a package only this machine wants.
#
# Copy to ~/.config/panama/hooks/post-upgrade and make it executable.
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Runs whenever the colour scheme or accent changes.
#
# $1 scheme: "dark" or "light"
# $2 accent: blue, orchid, teal, green, amber, orange, rose or slate
#
# Copy to ~/.config/panama/hooks/theme-set and make it executable. Anything in
# theme-set.d/ runs too, in sorted order, so several things can react without
# fighting over one file.
#
# A failure here is reported and stepped over: it will never cost you a theme
# change.
scheme="$1"
accent="$2"
# For example: repaint something Panama does not know about.
# printf 'set-theme %s\n' "$scheme" | nc -U "$HOME/.local/share/some-app/socket"
@@ -0,0 +1,80 @@
// Headless theme editor, for accent-controls-contract.
//
// The two halves of the editor that write colour: the four wells
// (ThemeEditorWells + ColorWell), which are the fast path and own hex
// validation and the eyedropper, and the six HSV rows (AccentEditor), which
// are the fine-tune behind a disclosure.
//
// AccentEditor commits on a debounce rather than per move, so `adjust` returns
// what is *pending*, not what is stored -- the caller waits and reads `status`
// again. That is the behaviour under test as much as the colour itself: a
// slider that wrote on every move spent a whole drag in apply-and-verify round
// trips and left the desktop repainting behind the pointer.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
import qs.modules.settings
import qs.services
ShellRoot {
Item {
width: 680
height: wells.implicitHeight + editor.implicitHeight
ThemeEditorWells {
id: wells
width: parent.width
}
AccentEditor {
id: editor
y: wells.implicitHeight
width: parent.width
}
}
IpcHandler {
target: "accent-controls-test"
function status(): string {
return JSON.stringify({
id: ThemeProfiles.activeProfile.id,
name: ThemeProfiles.activeProfile.name,
scheme: ThemeProfiles.activeProfile.scheme,
accent: String(ThemeProfiles.activeProfile.accent),
secondary: String(ThemeProfiles.activeProfile.secondary),
shipped: ThemeProfiles.activeProfile.shipped === true,
accentName: String(DesktopPreferences.get("accentName")),
bg: String(ThemeProfiles.activePalette.bg),
fg: String(ThemeProfiles.activePalette.fg),
fgDim: String(ThemeProfiles.activePalette.fgDim),
pending: editor.pending !== null,
wellError: wells.lastError
});
}
// Moves one HSV slider. The commit is debounced, so the answer is the
// pair the sliders are showing, not the stored one.
function adjust(target: string, channel: string, ratio: real): string {
editor.changeChannel(target, channel, ratio);
return JSON.stringify({
shown: { accent: editor.shownAccent, secondary: editor.shownSecondary },
pending: editor.pending !== null,
stored: {
accent: String(ThemeProfiles.activeProfile.accent),
secondary: String(ThemeProfiles.activeProfile.secondary)
}
});
}
// The one path every well takes -- typed hex, colour wheel and
// eyedropper all end here, so validation cannot differ between them.
function well(which: string, hex: string): string {
wells.apply(which, hex);
return status();
}
}
}
@@ -75,6 +75,21 @@ ShellRoot {
return AudioStreams.group(fixtureNodes, audioOutStreamFlag); return AudioStreams.group(fixtureNodes, audioOutStreamFlag);
} }
// A throwaway two-stream group for the clamp cases. The shared fixture
// above is mutated by the volume and mute tests, and a clamp assertion that
// depended on which of those ran first would be worthless.
function clampFixture(): var {
return {
key: "clamp",
label: "Clamp",
icon: "audio-x-generic-symbolic",
nodes: [
{ audio: { volume: 0.1, muted: true } },
{ audio: { volume: 0.1, muted: true } }
]
};
}
IpcHandler { IpcHandler {
target: "application-volume-test" target: "application-volume-test"
@@ -115,6 +130,31 @@ ShellRoot {
}); });
} }
// Over-amplification is a preference, so the ceiling is an argument
// rather than a constant -- this file stays Settings-free on purpose.
function clampVolume(): string {
const overAmp = clampFixture();
const overAmpChanged = AudioStreams.setVolume(overAmp, 1.4, 1.5);
const ceiling = clampFixture();
AudioStreams.setVolume(ceiling, 2.5, 1.5);
const defaultMax = clampFixture();
AudioStreams.setVolume(defaultMax, 1.4);
const floor = clampFixture();
AudioStreams.setVolume(floor, -0.5, 1.5);
const nonNumeric = clampFixture();
const nonNumericChanged = AudioStreams.setVolume(nonNumeric, "loud", 1.5);
return JSON.stringify({
overAmpChanged,
overAmp: overAmp.nodes.map(node => node.audio.volume),
overAmpMuted: overAmp.nodes.map(node => node.audio.muted),
ceiling: ceiling.nodes.map(node => node.audio.volume),
defaultMax: defaultMax.nodes.map(node => node.audio.volume),
floor: floor.nodes.map(node => node.audio.volume),
nonNumericChanged,
nonNumeric: nonNumeric.nodes.map(node => node.audio.volume)
});
}
function serviceSummary(): string { function serviceSummary(): string {
const applications = AudioDevices.applications; const applications = AudioDevices.applications;
return JSON.stringify({ return JSON.stringify({
@@ -57,7 +57,12 @@ Singleton {
const coerced = PreferenceSchema.coerce(key, value); const coerced = PreferenceSchema.coerce(key, value);
if (coerced === undefined) if (coerced === undefined)
return false; return false;
if (root.values[key] === coerced) // A json value comes out of coerce() with a fresh identity every time,
// so `===` never held for one and an identical write still bumped the
// revision. The revision is what drives the video restore, the scheme
// reconciliation and the theme coalesce timer, so re-storing the same
// display map ran all three again for a change nobody made.
if (root.sameStoredValue(key, coerced))
return true; return true;
// Reassign rather than mutate: QML does not notify on in-place changes // Reassign rather than mutate: QML does not notify on in-place changes
@@ -70,6 +75,19 @@ Singleton {
return true; return true;
} }
// Whether the store already holds `coerced` for `key`. Scalars compare by
// value; json compares by serialization, which is the only comparison an
// object or array has here. Two equal objects written in a different key
// order serialize differently and are treated as a change -- that is the
// old behaviour, so the comparison can only ever remove churn, never
// swallow a real write.
function sameStoredValue(key: string, coerced: var): bool {
const stored = root.values[key];
if (PreferenceSchema.spec(key)?.type === "json")
return stored !== undefined && JSON.stringify(stored) === JSON.stringify(coerced);
return stored === coerced;
}
// Restores every schema default in one write. Complete by construction -- // Restores every schema default in one write. Complete by construction --
// there is no hand-maintained list to fall out of sync with the schema. // there is no hand-maintained list to fall out of sync with the schema.
function resetDesktopDefaults(): void { function resetDesktopDefaults(): void {
@@ -101,7 +119,7 @@ Singleton {
const raw = (parsed && typeof parsed === "object") ? parsed : {}; const raw = (parsed && typeof parsed === "object") ? parsed : {};
// Upgrade before anything reads a value. A stored key the current // Upgrade before anything reads a value. A stored key the current
// schema no longer recognises is carried through untouched and silently // schema no longer recognizes is carried through untouched and silently
// stops taking effect, so the conversion has to happen here rather than // stops taking effect, so the conversion has to happen here rather than
// being noticed later by whoever owns that setting. // being noticed later by whoever owns that setting.
const result = Migrations.apply(raw); const result = Migrations.apply(raw);
@@ -130,6 +148,14 @@ Singleton {
printErrors: false printErrors: false
atomicWrites: true atomicWrites: true
// Adopt writes made from outside the shell -- a hand edit, a script,
// a restored snapshot -- instead of holding a stale copy in memory
// and silently erasing them at the next save. A change made anywhere
// must survive everywhere; the shell is the editor, not the owner.
// The shell's own atomic writes land here too and reload as a no-op.
watchChanges: true
onFileChanged: this.reload()
onLoaded: root.load() onLoaded: root.load()
// No file yet is the normal first-run case, not an error. // No file yet is the normal first-run case, not an error.
onLoadFailed: root.load() onLoadFailed: root.load()
@@ -145,7 +171,27 @@ Singleton {
Timer { Timer {
id: persistTimer id: persistTimer
interval: 0 interval: 0
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n") // Merge with what is on disk rather than overwriting it. This model
// was loaded at startup; a key written to the file since then -- a
// hand edit, a script, another shell instance during a session
// handoff -- would otherwise be erased by the next unrelated save,
// which is how a setting "changed itself back". Keys this shell has
// set win; keys it has never seen survive.
onTriggered: {
let disk = {};
try {
const text = preferencesFile.text();
if (text && text.trim().length > 0)
disk = JSON.parse(text);
} catch (error) {
// An unreadable file loses the merge, never the write.
}
if (!disk || typeof disk !== "object")
disk = {};
const merged = Object.assign({}, disk, root.values);
root.values = merged;
preferencesFile.setText(JSON.stringify(merged, null, 2) + "\n");
}
} }
// One-time move from the pre-Stage-1 location inside Quickshell's state // One-time move from the pre-Stage-1 location inside Quickshell's state
@@ -19,7 +19,7 @@ Singleton {
printErrors: false printErrors: false
atomicWrites: true atomicWrites: true
onSaved: root.saveError = "" onSaved: root.saveError = ""
onSaveFailed: error => root.saveError = "Could not save Home favourites." onSaveFailed: error => root.saveError = "Could not save Home favorites."
JsonAdapter { JsonAdapter {
id: values id: values
+1 -1
View File
@@ -5,7 +5,7 @@ pragma Singleton
// The schema is the single source of truth for what a setting IS, but it cannot // The schema is the single source of truth for what a setting IS, but it cannot
// describe what a setting USED to be. Renaming a key, changing its units, or // describe what a setting USED to be. Renaming a key, changing its units, or
// splitting one setting into two all leave a stored value that the new schema // splitting one setting into two all leave a stored value that the new schema
// does not recognise -- and an unrecognised key is silently carried through // does not recognize -- and an unrecognized key is silently carried through
// untouched, so the user's choice simply stops taking effect with nothing to // untouched, so the user's choice simply stops taking effect with nothing to
// say why. That is the failure this exists to prevent. // say why. That is the failure this exists to prevent.
// //
File diff suppressed because it is too large Load Diff
+69 -1
View File
@@ -19,6 +19,26 @@ Singleton {
readonly property bool showSeconds: DesktopPreferences.get("showSeconds") readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
readonly property bool showWeekday: DesktopPreferences.get("showWeekday") readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
// ── Bar ─────────────────────────────────────────────────────────────────
// Legibility first: the bar sits on the wallpaper, so it may need a tone,
// a shadow or a scrim the theme cannot know about. `barTextTone` itself is
// read by Theme, which turns it into the barFg family the widgets bind to.
readonly property bool barTextShadow: DesktopPreferences.get("barTextShadow")
readonly property bool barBackdrop: DesktopPreferences.get("barBackdrop")
// Which widgets earn a place. Each is ANDed with the widget's own state
// condition, so turning one on never conjures a pill with nothing in it.
readonly property bool showWeatherWidget: DesktopPreferences.get("showWeatherWidget")
readonly property bool showMediaWidget: DesktopPreferences.get("showMediaWidget")
readonly property bool showClipboardButton: DesktopPreferences.get("showClipboardButton")
readonly property bool showCalendarCountdown: DesktopPreferences.get("showCalendarCountdown")
// ── Control Center ──────────────────────────────────────────────────────
// One bool per section of the quick settings panel that is worth hiding.
readonly property bool ccShowFocus: DesktopPreferences.get("ccShowFocus")
readonly property bool ccShowHome: DesktopPreferences.get("ccShowHome")
readonly property bool ccShowPhone: DesktopPreferences.get("ccShowPhone")
// ── Weather ───────────────────────────────────────────────────────────── // ── Weather ─────────────────────────────────────────────────────────────
// Coordinates taken from the GNOME night-light setting, which had already // Coordinates taken from the GNOME night-light setting, which had already
// resolved the location. Uses Open-Meteo, which needs no API key. // resolved the location. Uses Open-Meteo, which needs no API key.
@@ -38,6 +58,26 @@ Singleton {
readonly property bool showCpu: DesktopPreferences.get("showCpu") readonly property bool showCpu: DesktopPreferences.get("showCpu")
readonly property bool showMemory: DesktopPreferences.get("showMemory") readonly property bool showMemory: DesktopPreferences.get("showMemory")
readonly property bool showGpu: DesktopPreferences.get("showGpu") readonly property bool showGpu: DesktopPreferences.get("showGpu")
readonly property bool showBattery: DesktopPreferences.get("showBattery")
readonly property bool showBatteryPercent: DesktopPreferences.get("showBatteryPercent")
readonly property bool showAgentUsage: DesktopPreferences.get("showAgentUsage")
// ── Agents ──────────────────────────────────────────────────────────────
// Who the desktop hands a failure to, what it is allowed to hand over, and
// which usage collectors run. `showAgentUsage` stays with the vitals above:
// it is the bar's switch, and the Agents page mirrors it.
readonly property string preferredAgent: DesktopPreferences.get("preferredAgent")
readonly property bool crashDiagnoseOffer: DesktopPreferences.get("crashDiagnoseOffer")
readonly property bool reloadFailureOffer: DesktopPreferences.get("reloadFailureOffer")
readonly property bool healthAgentHandoff: DesktopPreferences.get("healthAgentHandoff")
readonly property bool agentAutoApprove: DesktopPreferences.get("agentAutoApprove")
readonly property bool agentUsageClaude: DesktopPreferences.get("agentUsageClaude")
readonly property bool agentUsageCodex: DesktopPreferences.get("agentUsageCodex")
readonly property int agentUsageRefreshMinutes: DesktopPreferences.get("agentUsageRefreshMinutes")
// ── Battery ─────────────────────────────────────────────────────────────
readonly property int batteryLowPercent: DesktopPreferences.get("batteryLowPercent")
readonly property int batteryCriticalPercent: DesktopPreferences.get("batteryCriticalPercent")
// amdgpu exposes utilisation here. Verified present on this machine; the // amdgpu exposes utilisation here. Verified present on this machine; the
// widget hides itself if the path is missing rather than showing zeros. // widget hides itself if the path is missing rather than showing zeros.
@@ -55,6 +95,28 @@ Singleton {
readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire readonly property int notificationTimeoutCriticalMs: DesktopPreferences.get("notificationTimeoutCriticalMs") // 0 = never auto-expire
readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit") readonly property int notificationHistoryLimit: DesktopPreferences.get("notificationHistoryLimit")
readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts") readonly property int maxVisibleToasts: DesktopPreferences.get("maxVisibleToasts")
// The single exception to Do Not Disturb, read by the popup gate in
// services/Notifs.qml. Off means Do Not Disturb is absolute.
readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough")
// ── Accessibility ───────────────────────────────────────────────────────
// Reduce motion. Theme.qml turns this into the dur* tokens, so every
// Behavior and NumberAnimation in the shell obeys it without knowing it
// exists. Read through here rather than from the store directly because
// Theme reads it on every animated property in the shell.
readonly property bool animationsEnabled: DesktopPreferences.get("animationsEnabled")
// Read on every notification that would ring the bell, by the per-screen
// VisualBell overlay. Lives here rather than being read from the store
// directly, like every other value the shell consults at speed.
readonly property bool visualAlerts: DesktopPreferences.get("visualAlerts")
// ── Sound ───────────────────────────────────────────────────────────────
// Over-amplification is the clamp ceiling for output volume: off means 1.0,
// on means 1.5. Every slider and the volume keys read the same switch, so
// the ceiling cannot differ depending on where you changed the volume from.
readonly property bool overAmplification: DesktopPreferences.get("overAmplification")
readonly property bool volumeChangeBlip: DesktopPreferences.get("volumeChangeBlip")
// ── Focus ────────────────────────────────────────────────────────────── // ── Focus ──────────────────────────────────────────────────────────────
// One deliberate default rather than a preset picker: quick settings and // One deliberate default rather than a preset picker: quick settings and
@@ -62,7 +124,12 @@ Singleton {
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes") readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
// ── Dock ──────────────────────────────────────────────────────────────── // ── Dock ────────────────────────────────────────────────────────────────
// Pinned apps, in order, taken from the GNOME dash favourites. // Pinned apps, in order, taken from the GNOME dash favorites.
readonly property string dockPosition: DesktopPreferences.get("dockPosition")
readonly property var dockScreens: {
const stored = DesktopPreferences.get("dockScreens");
return Array.isArray(stored) ? stored : [];
}
readonly property var dockPinned: DesktopPreferences.get("dockPinned") readonly property var dockPinned: DesktopPreferences.get("dockPinned")
// Dash-to-Dock was set to intellihide against all windows: the dock hides // Dash-to-Dock was set to intellihide against all windows: the dock hides
@@ -84,4 +151,5 @@ Singleton {
// Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not // Passed to wf-recorder. Uses VAAPI on the AMD card so recording does not
// cost CPU while gaming. // cost CPU while gaming.
readonly property string recorderArgs: DesktopPreferences.get("recorderArgs") readonly property string recorderArgs: DesktopPreferences.get("recorderArgs")
readonly property string recorderAudio: DesktopPreferences.get("recorderAudio")
} }
+124 -45
View File
@@ -1,9 +1,9 @@
pragma Singleton pragma Singleton
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// Tokyo Night Moon — the single source of truth for every colour, radius, // Tokyo Night Moon — the single source of truth for every color, radius,
// duration and font in the shell. Nothing below this file should hardcode a // duration and font in the shell. Nothing below this file should hardcode a
// colour; if you find yourself writing "#" outside this file, add a token here. // color; if you find yourself writing "#" outside this file, add a token here.
// //
// The bar keeps GNOME's 36px information density but sits directly on the // The bar keeps GNOME's 36px information density but sits directly on the
// desktop, edge to edge. The dock retains the former glass vocabulary: bottom, // desktop, edge to edge. The dock retains the former glass vocabulary: bottom,
@@ -14,51 +14,117 @@ import Quickshell
// QtQuick is required even though nothing visual is declared here: `color` is a // QtQuick is required even though nothing visual is declared here: `color` is a
// QtQuick value type, and Qt.rgba() lives in its JS namespace. // QtQuick value type, and Qt.rgba() lives in its JS namespace.
import QtQuick import QtQuick
import qs.services
Singleton { Singleton {
id: root id: root
// ── Colour scheme ─────────────────────────────────────────────────────── // ── Color scheme ───────────────────────────────────────────────────────
// Tokyo Night ships an official light variant (Day), so light mode is that // The scheme still exists as a boolean because alphas and a handful of
// rather than a palette invented to merely not be dark. The two share the // mix directions depend on which side of the ground the theme sits — but
// same hues at different lightness, which is what lets the Prism identity // the palette itself now comes from the active theme record.
// survive the switch: blue still leads into orchid, it is simply a darker
// blue on a lighter ground.
// //
// Every token below is a binding on this, so flipping it repaints the whole // Every token below is a binding, so selecting a theme repaints the whole
// shell without anything needing to know it happened. // shell without anything needing to know it happened.
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light" readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light"
// ── Palette ───────────────────────────────────────────────────────────── // ── Palette ─────────────────────────────────────────────────────────────
// Canonical Tokyo Night Moon. `accent` matches the GNOME accent exactly. // The active theme's palette — ThemeProfiles resolves it and it is always
readonly property color bg: root.dark ? "#222436" : "#e1e2e7" // complete: a shipped theme carries its own, a custom theme without one
readonly property color bgDark: root.dark ? "#1e2030" : "#d3d5de" // inherits its scheme's default (Tokyo Moon or Tokyo Day) from the
readonly property color bgHighlight: root.dark ? "#2f334d" : "#c4c8da" // catalog. Nothing here needs a per-token fallback.
readonly property color bgPanel: root.dark ? "#2e2f3d" : "#d9dae3" // dock glass surface readonly property var palette: ThemeProfiles.activePalette
readonly property color bgPopover: root.dark ? "#21212f" : "#eaeaee" // Openbar submenu background
readonly property color fg: root.dark ? "#c8d3f5" : "#3760bf" readonly property color bg: root.palette.bg
readonly property color fgDim: root.dark ? "#828bb8" : "#6172b0" readonly property color bgDark: root.palette.bgDark
readonly property color fgMuted: root.dark ? "#636da6" : "#848cb5" readonly property color bgHighlight: root.palette.bgHighlight
readonly property color gutter: root.dark ? "#3b4261" : "#a8aecb" readonly property color bgPanel: root.palette.bgPanel // dock glass surface
readonly property color bgPopover: root.palette.bgPopover // Openbar submenu background
// The pair. `accent` is the primary and carries every state meaning readonly property color fg: root.palette.fg
// (focused, active, on). `accentSecondary` is the orchid from the tmux readonly property color fgDim: root.palette.fgDim
// theme — it never appears alone, only as the far end of a gradient. That readonly property color fgMuted: root.palette.fgMuted
// restraint is the whole point: the two colours meeting is the signature, readonly property color gutter: root.palette.gutter
// so the pink stops being special the moment it's used as a flat fill.
readonly property color accent: root.dark ? "#82aaff" : "#2e7de9" // blue // ── Bar text ────────────────────────────────────────────────────────────
readonly property color accentSecondary: root.dark ? "#b172b0" : "#9854f1" // orchid, from tmux // Every other surface in the shell draws on a ground the theme chose. The
readonly property color accentAlt: root.dark ? "#65bcff" : "#007197" // blue1, a lighter blue // bar draws on the wallpaper, which the theme has never seen — so a
readonly property color cyan: root.dark ? "#86e1fc" : "#007197" // photograph with a bright sky can leave the palette's own fg unreadable
readonly property color teal: root.dark ? "#4fd6be" : "#118c74" // exactly where the clock is.
readonly property color green: root.dark ? "#c3e88d" : "#587539" //
readonly property color yellow: root.dark ? "#ffc777" : "#8c6c3e" // Hence a second neutral family, used by the bar and nowhere else. Left
readonly property color orange: root.dark ? "#ff966c" : "#b15c00" // alone it *is* the fg family, so nothing changes for anyone who never
readonly property color red: root.dark ? "#ff757f" : "#f52a65" // asks. Forced light or dark, it becomes a run that survives whatever is
readonly property color redDeep: root.dark ? "#c53b53" : "#c64343" // underneath it, and the two dims are mixed toward the opposite end rather
readonly property color magenta: root.dark ? "#c099ff" : "#9854f1" // than picked by hand, so the three stay a family either way.
readonly property color pink: root.dark ? "#fca7ea" : "#d20065" readonly property string barTextTone: DesktopPreferences.get("barTextTone")
readonly property color barFg: {
if (root.barTextTone === "light")
return "#f4f6ff";
if (root.barTextTone === "dark")
return "#1b2130";
return root.fg;
}
readonly property color barFgDim: {
if (root.barTextTone === "light")
return root.mix(root.barFg, "#20242f", 0.35);
if (root.barTextTone === "dark")
return root.mix(root.barFg, "#ffffff", 0.35);
return root.fgDim;
}
readonly property color barFgMuted: {
if (root.barTextTone === "light")
return root.mix(root.barFg, "#20242f", 0.5);
if (root.barTextTone === "dark")
return root.mix(root.barFg, "#ffffff", 0.5);
return root.fgMuted;
}
// ── The accent ──────────────────────────────────────────────────────────
//
// `accent` is the primary and carries every state meaning (focused, active,
// on). `accentSecondary` never appears alone, only as the far end of a
// gradient. That restraint is the whole point: the two colors meeting is
// the signature, so the second color stops being special the moment it is
// used as a flat fill.
//
// NAMED accents rather than a free color. Each name carries a curated
// triple per scheme, because an arbitrary hex cannot work in both: a color
// legible on the Moon background is usually illegible on the Day one, and a
// picker that lets someone choose an unreadable desktop is not a feature.
// Every pair below measures at least 3:1 against the ground it sits on.
// This is also GNOME's model, which is the parity being chased.
//
// Blue is the shipped Prism -- blue leading, orchid following -- and stays
// the default.
//
// The table itself lives in services/ThemeProfileModel.js so a curated
// accent and a custom profile are the same kind of record. Its `gnome`
// member is the nearest name in GNOME's own accent-color enum, which is a
// fixed list of nine we do not get to extend; it is what libadwaita
// applications -- Files, Papers, Loupe -- are told to use, so choosing an
// accent here recolors them too instead of leaving them in GNOME blue.
readonly property var accents: ThemeProfiles.curatedAccents
readonly property var activeProfile: ThemeProfiles.activeProfile
// ThemeProfiles validates every persisted record before it can become
// active, so these bindings are both reactive and safe to expose as the
// shell-wide colour roles.
readonly property color accent: root.activeProfile.accent
readonly property color accentSecondary: root.activeProfile.secondary
readonly property color accentAlt: root.palette.accentAlt
readonly property color cyan: root.palette.cyan
readonly property color teal: root.palette.teal
readonly property color green: root.palette.green
readonly property color yellow: root.palette.yellow
readonly property color orange: root.palette.orange
readonly property color red: root.palette.red
readonly property color redDeep: root.palette.redDeep
readonly property color magenta: root.palette.magenta
readonly property color pink: root.palette.pink
// Semantic aliases — prefer these in widgets so intent survives a repaint. // Semantic aliases — prefer these in widgets so intent survives a repaint.
readonly property color ok: green readonly property color ok: green
@@ -75,6 +141,13 @@ Singleton {
// light one, and text on it stops being legible. // light one, and text on it stops being legible.
readonly property real dockAlpha: root.dark ? 0.34 : 0.62 readonly property real dockAlpha: root.dark ? 0.34 : 0.62
readonly property real popoverAlpha: root.dark ? 0.92 : 0.97 readonly property real popoverAlpha: root.dark ? 0.92 : 0.97
// Toasts sit a little lighter than a popover you opened on purpose. A
// notification arrives unbidden over whatever you were doing, and at full
// popover weight it reads as a dialog demanding an answer -- but the 6%
// foreground tint it used to have left the text competing with the desktop
// behind it. This is the point between the two.
readonly property real toastAlpha: root.dark ? 0.86 : 0.94
readonly property real overlayAlpha: root.dark ? 0.55 : 0.40 readonly property real overlayAlpha: root.dark ? 0.55 : 0.40
readonly property real hoverAlpha: root.dark ? 0.14 : 0.10 readonly property real hoverAlpha: root.dark ? 0.14 : 0.10
readonly property real activeAlpha: root.dark ? 0.24 : 0.18 readonly property real activeAlpha: root.dark ? 0.24 : 0.18
@@ -84,7 +157,7 @@ Singleton {
readonly property int barGap: 6 // breathing room below the bar for popovers readonly property int barGap: 6 // breathing room below the bar for popovers
readonly property int barSideMargin: 10 // inset for floating popovers readonly property int barSideMargin: 10 // inset for floating popovers
readonly property int dockIconSize: 48 readonly property int dockIconSize: DesktopPreferences.get("dockIconSize")
readonly property int dockPadding: 8 readonly property int dockPadding: 8
readonly property int dockGap: 8 readonly property int dockGap: 8
readonly property int dockRadius: 20 readonly property int dockRadius: 20
@@ -93,7 +166,6 @@ Singleton {
readonly property int popoverPadding: 14 readonly property int popoverPadding: 14
readonly property int popoverWidth: 380 readonly property int popoverWidth: 380
readonly property int controlCenterWidth: 430 readonly property int controlCenterWidth: 430
readonly property int controlCenterTopGap: 2
readonly property int cardRadius: 12 readonly property int cardRadius: 12
readonly property int pillRadius: 999 readonly property int pillRadius: 999
@@ -108,7 +180,7 @@ Singleton {
// Nerd Font, used ONLY to draw icon glyphs — never for text. It is the // Nerd Font, used ONLY to draw icon glyphs — never for text. It is the
// pragmatic alternative to freedesktop symbolic icons, which ship with a // pragmatic alternative to freedesktop symbolic icons, which ship with a
// hardcoded dark fill Qt will not recolour (see widgets/ThemedIcon.qml). // hardcoded dark fill Qt will not recolor (see widgets/ThemedIcon.qml).
readonly property string fontMono: DesktopPreferences.get("iconFont") readonly property string fontMono: DesktopPreferences.get("iconFont")
// Apply to any text whose digits change in place — clocks, percentages, // Apply to any text whose digits change in place — clocks, percentages,
@@ -132,14 +204,21 @@ Singleton {
// ── Motion ────────────────────────────────────────────────────────────── // ── Motion ──────────────────────────────────────────────────────────────
// Event-driven only. Nothing in this shell animates while idle — no pulse, // Event-driven only. Nothing in this shell animates while idle — no pulse,
// no shimmer, no spinners. These durations are used for open/close/hover. // no shimmer, no spinners. These durations are used for open/close/hover.
readonly property int durFast: 120 //
readonly property int durNormal: 200 // All of them collapse to zero when Reduce motion is on: the Accessibility
readonly property int durSlow: 320 // toggle used to still the compositor's windows while the shell's own bar,
// dock and panels kept moving, which made it a half-truth. A duration of 0
// is a completed animation, so every Behavior and NumberAnimation in the
// shell obeys the switch without knowing it exists.
readonly property bool motionEnabled: Settings.animationsEnabled
readonly property int durFast: motionEnabled ? 120 : 0
readonly property int durNormal: motionEnabled ? 200 : 0
readonly property int durSlow: motionEnabled ? 320 : 0
// The dock revealing is the one animation that answers a live pointer // The dock revealing is the one animation that answers a live pointer
// movement, so it gets its own (much shorter) duration. Anything slower // movement, so it gets its own (much shorter) duration. Anything slower
// reads as the desktop lagging behind the cursor rather than as motion. // reads as the desktop lagging behind the cursor rather than as motion.
readonly property int durDockReveal: 90 readonly property int durDockReveal: motionEnabled ? 90 : 0
// Matches the "snappy" spring curve defined in hypr/looks.lua. // Matches the "snappy" spring curve defined in hypr/looks.lua.
readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0] readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
@@ -152,7 +231,7 @@ Singleton {
} }
// Blend two tokens — used for hover/pressed states so we never invent a // Blend two tokens — used for hover/pressed states so we never invent a
// colour that isn't derived from the palette. // color that isn't derived from the palette.
function mix(a: color, b: color, ratio: real): color { function mix(a: color, b: color, ratio: real): color {
return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio); return Qt.rgba(a.r + (b.r - a.r) * ratio, a.g + (b.g - a.g) * ratio, a.b + (b.b - a.b) * ratio, a.a + (b.a - a.a) * ratio);
} }
@@ -163,7 +242,7 @@ Singleton {
// the active workspace pill and slider fills. // the active workspace pill and slider fills.
// //
// Where the gradient runs left-to-right the blue leads, because the eye // Where the gradient runs left-to-right the blue leads, because the eye
// reads left first and blue is the colour that carries meaning. // reads left first and blue is the color that carries meaning.
readonly property real prismStart: 0.22 readonly property real prismStart: 0.22
readonly property real prismEnd: 0.78 readonly property real prismEnd: 0.78
+70
View File
@@ -0,0 +1,70 @@
{
"_comment": "Generated-by-hand source of truth for every consumer outside QML. See config/dot/quickshell/config/PALETTE.md.",
"default": "blue",
"accents": {
"blue": {
"dark": "82aaff",
"darkSecondary": "b172b0",
"light": "2e7de9",
"lightSecondary": "9854f1",
"label": "Prism blue",
"gnome": "blue"
},
"orchid": {
"dark": "c099ff",
"darkSecondary": "fca7ea",
"light": "7847bd",
"lightSecondary": "9854f1",
"label": "Orchid",
"gnome": "purple"
},
"teal": {
"dark": "86e1fc",
"darkSecondary": "82aaff",
"light": "007197",
"lightSecondary": "2e7de9",
"label": "Teal",
"gnome": "teal"
},
"green": {
"dark": "c3e88d",
"darkSecondary": "86e1fc",
"light": "587539",
"lightSecondary": "007197",
"label": "Green",
"gnome": "green"
},
"amber": {
"dark": "ffc777",
"darkSecondary": "ff966c",
"light": "8c6c3e",
"lightSecondary": "b15c00",
"label": "Amber",
"gnome": "yellow"
},
"orange": {
"dark": "ff966c",
"darkSecondary": "ff757f",
"light": "b15c00",
"lightSecondary": "c64343",
"label": "Orange",
"gnome": "orange"
},
"rose": {
"dark": "ff757f",
"darkSecondary": "c099ff",
"light": "f52a65",
"lightSecondary": "9854f1",
"label": "Rose",
"gnome": "red"
},
"slate": {
"dark": "828bb8",
"darkSecondary": "82aaff",
"light": "6172b0",
"lightSecondary": "2e7de9",
"label": "Slate",
"gnome": "slate"
}
}
}
+217
View File
@@ -0,0 +1,217 @@
{
"comment": "The shipped theme catalog — the one source of truth for full palettes. services/ThemeCatalog.qml reads it with FileView, scripts read it with jq, contracts read it with fs. The 19 palette keys are exactly Theme.qml's color tokens; ansi's 16 feed generated terminal themes. moon and day must stay byte-identical to the shell's pre-theme literals: theme-catalog-contract pins that.",
"defaultDark": "moon",
"defaultLight": "day",
"themes": [
{
"id": "moon", "name": "Tokyo Moon", "scheme": "dark",
"accent": "#82aaff", "secondary": "#b172b0",
"palette": {
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
"gutter": "#3b4261", "accentAlt": "#65bcff",
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
},
"ansi": {
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
"cyan": "#86e1fc", "white": "#828bb8",
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
}
},
{
"id": "moon-rose", "name": "Moon Rose", "scheme": "dark",
"accent": "#ff757f", "secondary": "#c099ff",
"palette": {
"bg": "#222436", "bgDark": "#1e2030", "bgHighlight": "#2f334d",
"bgPanel": "#2e2f3d", "bgPopover": "#21212f",
"fg": "#c8d3f5", "fgDim": "#828bb8", "fgMuted": "#636da6",
"gutter": "#3b4261", "accentAlt": "#fca7ea",
"cyan": "#86e1fc", "teal": "#4fd6be", "green": "#c3e88d",
"yellow": "#ffc777", "orange": "#ff966c", "red": "#ff757f",
"redDeep": "#c53b53", "magenta": "#c099ff", "pink": "#fca7ea"
},
"ansi": {
"black": "#1b1d2b", "red": "#ff757f", "green": "#c3e88d",
"yellow": "#ffc777", "blue": "#82aaff", "magenta": "#c099ff",
"cyan": "#86e1fc", "white": "#828bb8",
"brightBlack": "#444a73", "brightRed": "#ff8d94", "brightGreen": "#c7fb6d",
"brightYellow": "#ffd8ab", "brightBlue": "#9ab8ff", "brightMagenta": "#caabff",
"brightCyan": "#b2ebff", "brightWhite": "#c8d3f5"
}
},
{
"id": "mocha", "name": "Catppuccin Mocha", "scheme": "dark",
"accent": "#cba6f7", "secondary": "#f5c2e7",
"palette": {
"bg": "#1e1e2e", "bgDark": "#181825", "bgHighlight": "#313244",
"bgPanel": "#27273a", "bgPopover": "#1b1b2a",
"fg": "#cdd6f4", "fgDim": "#9399b2", "fgMuted": "#7f849c",
"gutter": "#45475a", "accentAlt": "#89dceb",
"cyan": "#89dceb", "teal": "#94e2d5", "green": "#a6e3a1",
"yellow": "#f9e2af", "orange": "#fab387", "red": "#f38ba8",
"redDeep": "#b04a5a", "magenta": "#cba6f7", "pink": "#f5c2e7"
},
"ansi": {
"black": "#45475a", "red": "#f38ba8", "green": "#a6e3a1",
"yellow": "#f9e2af", "blue": "#89b4fa", "magenta": "#f5c2e7",
"cyan": "#94e2d5", "white": "#bac2de",
"brightBlack": "#585b70", "brightRed": "#f38ba8", "brightGreen": "#a6e3a1",
"brightYellow": "#f9e2af", "brightBlue": "#89b4fa", "brightMagenta": "#f5c2e7",
"brightCyan": "#94e2d5", "brightWhite": "#a6adc8"
}
},
{
"id": "nord", "name": "Nord", "scheme": "dark",
"accent": "#88c0d0", "secondary": "#81a1c1",
"palette": {
"bg": "#2e3440", "bgDark": "#272c36", "bgHighlight": "#3b4252",
"bgPanel": "#353c4a", "bgPopover": "#2a303c",
"fg": "#d8dee9", "fgDim": "#9aa4b8", "fgMuted": "#7b8598",
"gutter": "#4c566a", "accentAlt": "#8fbcbb",
"cyan": "#88c0d0", "teal": "#8fbcbb", "green": "#a3be8c",
"yellow": "#ebcb8b", "orange": "#d08770", "red": "#bf616a",
"redDeep": "#a54e57", "magenta": "#b48ead", "pink": "#c9a3c0"
},
"ansi": {
"black": "#3b4252", "red": "#bf616a", "green": "#a3be8c",
"yellow": "#ebcb8b", "blue": "#81a1c1", "magenta": "#b48ead",
"cyan": "#88c0d0", "white": "#e5e9f0",
"brightBlack": "#4c566a", "brightRed": "#bf616a", "brightGreen": "#a3be8c",
"brightYellow": "#ebcb8b", "brightBlue": "#81a1c1", "brightMagenta": "#b48ead",
"brightCyan": "#8fbcbb", "brightWhite": "#eceff4"
}
},
{
"id": "gruvbox", "name": "Gruvbox", "scheme": "dark",
"accent": "#fabd2f", "secondary": "#fe8019",
"palette": {
"bg": "#282828", "bgDark": "#1d2021", "bgHighlight": "#3c3836",
"bgPanel": "#32302f", "bgPopover": "#211f1e",
"fg": "#ebdbb2", "fgDim": "#a89984", "fgMuted": "#928374",
"gutter": "#504945", "accentAlt": "#83a598",
"cyan": "#83a598", "teal": "#8ec07c", "green": "#b8bb26",
"yellow": "#fabd2f", "orange": "#fe8019", "red": "#fb4934",
"redDeep": "#cc241d", "magenta": "#d3869b", "pink": "#dfa7be"
},
"ansi": {
"black": "#282828", "red": "#cc241d", "green": "#98971a",
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
"cyan": "#689d6a", "white": "#a89984",
"brightBlack": "#928374", "brightRed": "#fb4934", "brightGreen": "#b8bb26",
"brightYellow": "#fabd2f", "brightBlue": "#83a598", "brightMagenta": "#d3869b",
"brightCyan": "#8ec07c", "brightWhite": "#ebdbb2"
}
},
{
"id": "everforest", "name": "Everforest", "scheme": "dark",
"accent": "#a7c080", "secondary": "#e69875",
"palette": {
"bg": "#2d353b", "bgDark": "#272e33", "bgHighlight": "#3d484d",
"bgPanel": "#343f44", "bgPopover": "#293136",
"fg": "#d3c6aa", "fgDim": "#9da9a0", "fgMuted": "#7a8478",
"gutter": "#475258", "accentAlt": "#7fbbb3",
"cyan": "#7fbbb3", "teal": "#83c092", "green": "#a7c080",
"yellow": "#dbbc7f", "orange": "#e69875", "red": "#e67e80",
"redDeep": "#c05a5e", "magenta": "#d699b6", "pink": "#dfa7c1"
},
"ansi": {
"black": "#3d484d", "red": "#e67e80", "green": "#a7c080",
"yellow": "#dbbc7f", "blue": "#7fbbb3", "magenta": "#d699b6",
"cyan": "#83c092", "white": "#d3c6aa",
"brightBlack": "#475258", "brightRed": "#e67e80", "brightGreen": "#a7c080",
"brightYellow": "#dbbc7f", "brightBlue": "#7fbbb3", "brightMagenta": "#d699b6",
"brightCyan": "#83c092", "brightWhite": "#d3c6aa"
}
},
{
"id": "day", "name": "Tokyo Day", "scheme": "light",
"accent": "#2e7de9", "secondary": "#9854f1",
"palette": {
"bg": "#e1e2e7", "bgDark": "#d3d5de", "bgHighlight": "#c4c8da",
"bgPanel": "#d9dae3", "bgPopover": "#eaeaee",
"fg": "#3760bf", "fgDim": "#6172b0", "fgMuted": "#848cb5",
"gutter": "#a8aecb", "accentAlt": "#007197",
"cyan": "#007197", "teal": "#118c74", "green": "#587539",
"yellow": "#8c6c3e", "orange": "#b15c00", "red": "#f52a65",
"redDeep": "#c64343", "magenta": "#9854f1", "pink": "#d20065"
},
"ansi": {
"black": "#b4b5b9", "red": "#f52a65", "green": "#587539",
"yellow": "#8c6c3e", "blue": "#2e7de9", "magenta": "#9854f1",
"cyan": "#007197", "white": "#6172b0",
"brightBlack": "#a1a6c5", "brightRed": "#ff4774", "brightGreen": "#5c8524",
"brightYellow": "#a27629", "brightBlue": "#358aff", "brightMagenta": "#a463ff",
"brightCyan": "#007ea8", "brightWhite": "#3760bf"
}
},
{
"id": "latte", "name": "Catppuccin Latte", "scheme": "light",
"accent": "#8839ef", "secondary": "#ea76cb",
"palette": {
"bg": "#eff1f5", "bgDark": "#e6e9ef", "bgHighlight": "#ccd0da",
"bgPanel": "#e2e6ee", "bgPopover": "#f5f7fa",
"fg": "#4c4f69", "fgDim": "#6c6f85", "fgMuted": "#8c8fa1",
"gutter": "#acb0be", "accentAlt": "#209fb5",
"cyan": "#04a5e5", "teal": "#179299", "green": "#40a02b",
"yellow": "#df8e1d", "orange": "#fe640b", "red": "#d20f39",
"redDeep": "#e64553", "magenta": "#8839ef", "pink": "#ea76cb"
},
"ansi": {
"black": "#5c5f77", "red": "#d20f39", "green": "#40a02b",
"yellow": "#df8e1d", "blue": "#1e66f5", "magenta": "#ea76cb",
"cyan": "#179299", "white": "#acb0be",
"brightBlack": "#6c6f85", "brightRed": "#d20f39", "brightGreen": "#40a02b",
"brightYellow": "#df8e1d", "brightBlue": "#1e66f5", "brightMagenta": "#ea76cb",
"brightCyan": "#179299", "brightWhite": "#bcc0cc"
}
},
{
"id": "gruvbox-light", "name": "Gruvbox Light", "scheme": "light",
"accent": "#b57614", "secondary": "#af3a03",
"palette": {
"bg": "#fbf1c7", "bgDark": "#f2e5bc", "bgHighlight": "#ebdbb2",
"bgPanel": "#f0e6c0", "bgPopover": "#fdf6d8",
"fg": "#3c3836", "fgDim": "#665c54", "fgMuted": "#7c6f64",
"gutter": "#bdae93", "accentAlt": "#076678",
"cyan": "#076678", "teal": "#427b58", "green": "#79740e",
"yellow": "#b57614", "orange": "#af3a03", "red": "#9d0006",
"redDeep": "#cc241d", "magenta": "#8f3f71", "pink": "#b16286"
},
"ansi": {
"black": "#fbf1c7", "red": "#cc241d", "green": "#98971a",
"yellow": "#d79921", "blue": "#458588", "magenta": "#b16286",
"cyan": "#689d6a", "white": "#7c6f64",
"brightBlack": "#928374", "brightRed": "#9d0006", "brightGreen": "#79740e",
"brightYellow": "#b57614", "brightBlue": "#076678", "brightMagenta": "#8f3f71",
"brightCyan": "#427b58", "brightWhite": "#3c3836"
}
},
{
"id": "everforest-light", "name": "Everforest Light", "scheme": "light",
"accent": "#8da101", "secondary": "#f57d26",
"palette": {
"bg": "#fdf6e3", "bgDark": "#f4f0d9", "bgHighlight": "#e6e2cc",
"bgPanel": "#efebd4", "bgPopover": "#faf3dc",
"fg": "#5c6a72", "fgDim": "#829181", "fgMuted": "#939f91",
"gutter": "#a6b0a0", "accentAlt": "#3a94c5",
"cyan": "#3a94c5", "teal": "#35a77c", "green": "#8da101",
"yellow": "#dfa000", "orange": "#f57d26", "red": "#f85552",
"redDeep": "#e66868", "magenta": "#df69ba", "pink": "#df69ba"
},
"ansi": {
"black": "#5c6a72", "red": "#f85552", "green": "#8da101",
"yellow": "#dfa000", "blue": "#3a94c5", "magenta": "#df69ba",
"cyan": "#35a77c", "white": "#e0dcc7",
"brightBlack": "#829181", "brightRed": "#f85552", "brightGreen": "#8da101",
"brightYellow": "#dfa000", "brightBlue": "#3a94c5", "brightMagenta": "#df69ba",
"brightCyan": "#35a77c", "brightWhite": "#fdf6e3"
}
}
]
}
@@ -75,6 +75,36 @@ ShellRoot {
}))); })));
} }
// One display is the common case on a laptop, and the canvas is the
// page's hero now: it renders that display rather than disappearing and
// leaving a picker for a list of one. Only dragging goes away, because
// there is nothing to arrange it against.
function soloFixture(): string {
const previous = fixtureService.monitors;
fixtureService.monitors = [previous[0]];
arrangement.resetDraft();
const snapshot = arrangement.canvasSnapshot();
fixtureService.monitors = previous;
arrangement.resetDraft();
return JSON.stringify(snapshot);
}
// A mirrored display has no position of its own -- the compositor puts
// it on top of its target -- so the canvas stacks it there and says so
// rather than drawing it wherever its stale coordinates point.
function mirrorFixture(): string {
const previous = fixtureService.monitors;
fixtureService.monitors = [
previous[0],
Object.assign({}, previous[1], { mirrorOf: "DP-2" })
];
arrangement.resetDraft();
const snapshot = arrangement.canvasSnapshot();
fixtureService.monitors = previous;
arrangement.resetDraft();
return JSON.stringify(snapshot);
}
function identify(): void { Displays.identify(); } function identify(): void { Displays.identify(); }
function identifying(): bool { return Displays.identifying; } function identifying(): bool { return Displays.identifying; }
} }
@@ -10,9 +10,53 @@ ShellRoot {
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false } { name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 1, x: 3140, y: 80, primary: false }
] ]
// The same two displays, with the second mirroring the first. A mirrored
// display has no position of its own: the compositor puts it on top of its
// target, so its stored coordinates are stale the moment mirroring is on.
readonly property var mirrored: [
{ name: "DP-2", width: 4500, height: 3000, scale: 1.5, transform: 0, x: 140, y: 80, primary: true, mirrorOf: "" },
{ name: "HDMI-A-1", width: 2560, height: 1440, scale: 1, transform: 0, x: 3140, y: 80, primary: false, mirrorOf: "DP-2" }
]
IpcHandler { IpcHandler {
target: "display-layout-test" target: "display-layout-test"
function mirror(): string {
const normalized = DisplayLayout.normalize(mirrored);
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
return JSON.stringify({
valid: DisplayLayout.validate(mirrored),
normalized: normalized.map(record => ({
name: record.name, x: record.x, y: record.y,
primary: record.primary, mirrorOf: record.mirrorOf ?? ""
})),
bounds: canvas.bounds,
rects: canvas.rects
});
}
function invalidMirrors(): string {
const base = mirrored.map(record => Object.assign({}, record));
const cases = [];
const add = layout => cases.push(DisplayLayout.validate(layout));
// Mirroring itself.
add([base[0], Object.assign({}, base[1], { mirrorOf: "HDMI-A-1" })]);
// Mirroring an output that is not in the layout.
add([base[0], Object.assign({}, base[1], { mirrorOf: "NOPE-1" })]);
// The primary may not mirror: the desktop is anchored on it.
add([Object.assign({}, base[0], { mirrorOf: "HDMI-A-1" }), base[1]]);
// No chains. Hyprland resolves a mirror to one target, and a chain
// is a question nobody can answer from the canvas.
add([
base[0],
Object.assign({}, base[1], { mirrorOf: "DP-3" }),
{ name: "DP-3", width: 1920, height: 1080, scale: 1, transform: 0, x: 6000, y: 0, primary: false, mirrorOf: "DP-2" }
]);
// Not a name at all.
add([base[0], Object.assign({}, base[1], { mirrorOf: 5 })]);
return JSON.stringify(cases);
}
function status(): string { function status(): string {
const normalized = DisplayLayout.normalize(fixture); const normalized = DisplayLayout.normalize(fixture);
const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20); const canvas = DisplayLayout.canvasRects(normalized, 800, 500, 20);
+67 -2
View File
@@ -6,6 +6,10 @@ import qs.config
import qs.services import qs.services
ShellRoot { ShellRoot {
// The isolated screen model begins with both fixture outputs so changing
// it below exercises the same reactive topology path as a real hotplug.
Component.onCompleted: Displays.screenOverride = ["DP-2", "HDMI-A-1"]
IpcHandler { IpcHandler {
target: "displays-test" target: "displays-test"
@@ -28,7 +32,14 @@ ShellRoot {
canConfirm: Displays.canConfirm, canConfirm: Displays.canConfirm,
secondsLeft: Displays.secondsLeft, secondsLeft: Displays.secondsLeft,
lastError: Displays.lastError, lastError: Displays.lastError,
overridden: monitor ? Displays.isOverridden(monitor.name) : false overridden: monitor ? Displays.isOverridden(monitor.name) : false,
// The compositor being visually restored is not the service
// being done: revert verification keeps its own readback
// running for a few ticks, and busy blocks a new apply until
// it settles. A caller that only watched `awaiting` raced
// this and got a refusal with no error text.
settled: !Displays.busy && !Displays.revertVerificationActive
&& !Displays.awaitingConfirmation
}); });
} }
@@ -42,6 +53,7 @@ ShellRoot {
function transactionStatus(): string { function transactionStatus(): string {
return JSON.stringify({ return JSON.stringify({
layout: Displays.currentLayout(), layout: Displays.currentLayout(),
primaryFirst: Displays.primaryFirstMonitors.map(monitor => monitor.name),
pending: Displays.pendingRequestedLayout, pending: Displays.pendingRequestedLayout,
previous: Displays.pendingPreviousLayout, previous: Displays.pendingPreviousLayout,
reverting: Displays.revertExpectedLayout, reverting: Displays.revertExpectedLayout,
@@ -74,6 +86,56 @@ ShellRoot {
Displays.parse(text, generation); Displays.parse(text, generation);
} }
// The restore-on-reconnect decision, without applying anything. The
// caller injects a topology with injectReadback first, sets the stored
// arrangement here, and reads back what Panama would do about it.
// Both arguments are base64. `qs ipc call` splits an argument that
// looks like a JSON array of several objects into one argument per
// object, so a two-monitor fixture arrives as two arguments and the
// call is rejected for arity. Encoding sidesteps the parsing entirely.
//
// Topology and stored arrangement are set in one call on purpose: a
// real compositor query landing between two calls would replace the
// injected topology, and the answer would be about this machine's
// actual monitor instead of the fixture.
function restorePlan(readbackB64: string, storedB64: string): string {
Displays.parse(Qt.atob(readbackB64), 0);
DesktopPreferences.set("displays", JSON.parse(Qt.atob(storedB64)));
const plan = Displays.plannedRestore();
return JSON.stringify({
action: plan.action,
layout: (plan.layout ?? []).map(record => ({
name: record.name, mode: record.mode, scale: record.scale,
transform: record.transform, x: record.x, y: record.y,
primary: record.primary
}))
});
}
// The extended record — vrr override, colour profile, bit depth, SDR
// trim, mirroring — merged one field at a time, which is how every
// control on the page changes it. Base64 for the reason restorePlan
// documents above: `qs ipc call` splits JSON that looks like an array
// of objects into one argument per object.
function applyRecordFixture(output: string, patchB64: string): bool {
return Displays.applyRecord(output, JSON.parse(Qt.atob(patchB64)));
}
// matchesLayout as a pure function. The readback carve-outs — a
// mirrored output's position, a framebuffer format Panama does not
// recognise — are decisions about what NOT to assert, and proving them
// through a compositor would mean owning a compositor that mirrors.
function layoutMatch(monitorsB64: string, layoutB64: string): bool {
return Displays.matchesLayout(JSON.parse(Qt.atob(monitorsB64)),
JSON.parse(Qt.atob(layoutB64)));
}
// A settings.json written before any of the new fields existed is the
// common case on every machine that has this installed today.
function persistedEntryValid(entryB64: string): bool {
return Displays.isPersistedLayoutEntry(JSON.parse(Qt.atob(entryB64)));
}
function expireApplyVerification(): void { function expireApplyVerification(): void {
Displays.verificationTimedOut(); Displays.verificationTimedOut();
} }
@@ -83,7 +145,7 @@ ShellRoot {
} }
function refreshIdentityFixture(): string { function refreshIdentityFixture(): string {
const modes = Displays.normaliseModes([ const modes = Displays.normalizeModes([
"[email protected]", "[email protected]",
"[email protected]" "[email protected]"
]); ]);
@@ -141,5 +203,8 @@ ShellRoot {
if (monitor) Displays.forget(monitor.name); if (monitor) Displays.forget(monitor.name);
} }
function refresh(): void { Displays.refresh(); } function refresh(): void { Displays.refresh(); }
function setScreenModel(names: string): void {
Displays.screenOverride = JSON.parse(names);
}
} }
} }
+4
View File
@@ -19,5 +19,9 @@ ShellRoot {
function repair(id: string): bool { return Health.repair(id, false); } function repair(id: string): bool { return Health.repair(id, false); }
function report(): string { return JSON.stringify(Health.snapshot, null, 2); } function report(): string { return JSON.stringify(Health.snapshot, null, 2); }
function copy(): bool { return Health.copyReport(); } function copy(): bool { return Health.copyReport(); }
// Both take an explicit destination or subject, so nothing here writes
// to the real home or rescans anything the fixture did not ask for.
function save(path: string): bool { return Health.saveReport(path); }
function recheck(id: string): bool { return Health.refreshCheck(id); }
} }
} }
@@ -0,0 +1,80 @@
# Coming from another desktop
Panama is Hyprland with a shell built to replace a GNOME session, so most of
what you know still applies. The differences worth knowing on day one are
below. Nothing here is a preference you have to accept: every one of them is
adjustable in Settings.
## Windows arrange themselves
The biggest change. You do not drag windows into position. Open a second
window and the screen splits; open a third and it splits again. This is
called tiling, and after a day of it, dragging windows feels like making
your own bed in a hotel.
Two things follow from it:
- **There is no minimise.** A minimised window is a window you have hidden
from a layout that was managing it for you. Use `Super + Shift + X` to
push a window to the scratchpad and `Super + X` to bring it back, which is
the same idea without the layout losing track of it.
- **Floating still exists.** `Super + Ctrl + C` makes a window float above
the layout, for the dialog that was never meant to be tiled.
## Workspaces replace the taskbar
Instead of many windows on one screen, use many screens. `Alt + 1` through
`Alt + 0` jump straight to a workspace; `Alt + Shift + 1` sends the window
you are looking at to workspace one. `Super + backtick` shows all of them at
once.
If you came from macOS, this is Spaces with the friction removed. If you came
from Windows, it is virtual desktops that are actually worth using because
switching is one key rather than three.
## The launcher is the menu
There is no applications menu to browse. `Super + Space` opens a search box
that finds applications, files, clipboard history, emoji, and does
arithmetic. Type three letters of what you want.
This replaces the Start menu, Spotlight, the GNOME overview's app grid, and
the run dialog, and it is faster than all of them because it never asks you
to aim at anything.
## What stayed the same
`Super + Tab` still switches windows. The bar is still along the top with a
clock in the middle and status on the right. There is still a dock. Volume
and brightness keys still work, and still work on the lock screen. Copy and
paste are unchanged. Your files are where you left them.
## Where the settings went
`Super + I` opens Panama's own Settings. The sidebar is where GNOME's panels
ended up, and it is worth one read so you never have to hunt:
| If you are looking for | It is under |
|---|---|
| Themes, wallpaper, fonts, text size, titlebars | Appearance |
| The bar, the dock, Control Center, tiling, workspaces | Shell |
| Resolution, scale, arrangement, night light | Displays |
| Output and input devices, per-application volume | Sound |
| Do Not Disturb, per-application rules, focus modes | Notifications & Focus |
| Shortcuts, mouse and touchpad, dictation | Input |
| Wi-Fi and VPN, the firewall, sharing, printers | Network & Sharing |
| Default applications, autostart, gaming, Screen Intelligence | Applications |
| The accounts on this machine, and online ones | Users & Accounts |
| Permissions, saved passwords, what the machine remembers, SSH keys | Privacy & Security |
| Battery, the lid, the power button, idle and lock timings | Power & Lock |
| Magnifier, color filters, reduced motion, screen reader | Accessibility |
| Updates, storage, snapshots, date and time, backups | System |
Home is the page `Super + I` lands on. The search box above the sidebar
searches the settings themselves rather than the page names, so typing what you
want to change is usually faster than remembering which of those it lives in.
GNOME Settings is still installed, and Panama's Settings opens it in exactly
two places: adding an online account, which has to go through the provider's
own dialog, and Digital wellbeing, which is the one panel of GNOME's that does
something Panama does not.
@@ -0,0 +1,66 @@
# The keyboard
Press `Super + /` at any time to see every shortcut this machine has. That
list is generated from the live keymap, so it is never out of date, and it
includes anything you have rebound.
This chapter is the shape behind that list, which is the part worth learning
once.
## Three modifiers, three jobs
| Modifier | Acts on |
|---|---|
| `Super` | The window you are looking at |
| `Alt` | Workspaces |
| `Super + Ctrl` | The layout itself: splitting, floating, swapping |
| `Super + Alt` | The application you already have open |
Almost every shortcut follows from this. `Super + H` moves focus left.
`Alt + H` moves to the workspace on the left. `Super + Ctrl + H` swaps the
window leftward in the layout.
## Directions
`H`, `J`, `K`, `L` mean left, down, up, right, as they do in vim. The arrow
keys work everywhere the letters do, so you never have to learn them if you
would rather not.
Add `Shift` to move the window rather than the focus. `Super + Shift + L`
takes the current window and moves it to the right.
## Opening versus going to
`Super + T` opens a terminal. It opens another one every time, which on a
tiling desktop is the normal way to work rather than an accident.
`Super + Alt + T` goes to a terminal you already have, wherever it is, and
opens one only if there is none. The same pair works for the editor, browser,
files, calculator and mail.
So the plain key means "give me one" and adding `Alt` means "take me to the
one I have".
## The handful worth memorising first
| Keys | What it does |
|---|---|
| `Super + Space` | Find and open anything |
| `Super + T` | Terminal (another one) |
| `Super + Alt + T` | The terminal you already have |
| `Super + Q` | Close the window |
| `Super + backtick` | Every window, every workspace |
| `Super + I` | Settings |
| `Super + /` | Every other shortcut |
## Changing them
Settings has a [Keyboard page](panama://settings/shortcuts), under Input, listing every bind, each of which
can be reassigned. A rebind moves the shortcut and cannot change what it does, so
there is no way to make a key do something unexpected by editing it.
Shortcuts you invent live in the same list, in a **Custom** group above the
rest. One of those names an application, a shell action or a window move rather
than storing a command — for the same reason: what you are choosing is which
key, not what it runs. A chord something already holds is refused rather than
stolen, so a new shortcut can never quietly cost you an old one.
@@ -0,0 +1,77 @@
# Windows and workspaces
## How the split is chosen
A new window splits the space of the window that had focus. Whether it splits
beside or below depends on which way that space is longer, which usually
matches what you were about to do anyway.
When it does not, `Super + Ctrl + G` flips the split of the focused window,
and `Super + Ctrl + Z` and `Super + Ctrl + V` decide the direction the *next*
window will take before you open it.
## Resizing
`Super + Shift` plus `Y`, `O`, `B`, `M` adjusts width; `I`, `U`, `N` and
comma adjust height. These are inverted from what their letters suggest, on
purpose, and the reason is written in `config/dot/hypr/keybinds.lua` next to
the binds.
For anything finer, hold `Super` and drag with the right mouse button.
## Fullscreen and floating
`Super + U` makes the focused window fullscreen. `Super + Ctrl + C` lifts it
out of the layout to float, and `Super + Ctrl + Shift + C` pins it so it
follows you between workspaces. A floating window can be moved by holding
`Super` and dragging with the left button.
## Workspaces
Ten you can jump straight to, on `Alt + 1` through `Alt + 0`, though they are
created and destroyed as you use them rather than sitting there empty. `Alt + H`
and `Alt + L` step between neighbours — right past the last one makes a new one,
left from the first stays put — and `Alt + Shift` plus a number sends the
current window somewhere without following it.
On a machine with more than one display, workspaces belong to whichever
screen has focus. If you would rather pin workspaces one through ten to your
main display and give the second screen its own, that is a switch on the
Displays settings page.
How windows share the space — the layout, the master area, gaps and window
edges — is Shell Tiling. The workspace switches themselves, and the projects
below, are Shell Workspaces.
## The touchpad
Three fingers sideways moves between workspaces and follows your fingers as it
goes; three fingers up opens the overview and three down closes it. Those are
GNOME's gestures, reproduced, and they do not move.
Four fingers are yours. Each of the four directions can be given a job — a shell
action, a window or workspace move — from the same list your own shortcuts pick
from, on Input Mouse & Touchpad. Assigning one reloads the compositor, so
expect a beat of black before it works. Unassigned directions are left alone
rather than bound to nothing.
## Rules for one application
Some windows want the same treatment every time: the password manager should
float, chat should open on workspace four, a picture viewer should not be
resized to fill half the screen. **Application window rules** on Shell Tiling is where that
is written down. You pick an application and tick what should happen to it —
float, center it, pin it to every workspace, give it a fixed size or a
workspace to open on — rather than writing a match pattern. Panama's own
surfaces cannot be targeted, and a rule with nothing ticked is refused rather
than saved as a row you would wonder about later.
## Projects
A project is the set of windows you open together: which applications, which
workspace each was on, and for a terminal, which directory it was in.
Arrange things the way you want them, then run **Save Layout as Project**
from the launcher. **Open Project** lays it out again on free workspaces, so
it never lands on top of what you are already doing. An application that
refuses to open twice is moved into place rather than launched again.
@@ -0,0 +1,83 @@
# When something breaks
## Start here
Run **Check System Health** from the launcher, or open
[System Health](panama://settings/services). It reports what is actually running rather than what was
installed, and it can repair several things itself.
From a terminal, the same check is `panama doctor`.
## The shell is gone
If the bar and dock disappear, the Quickshell process died. **Restart Desktop
Shell** from the launcher brings it back. If the launcher is also gone, press
`Super + Shift + R` for the fallback launcher, which depends on nothing but
itself and exists for exactly this.
From a terminal: `qs -d` starts the shell again.
## A shortcut stopped working
Check `Super + /` first: if the shortcut shows a different chord than you
expect, something rebound it. The Keyboard page, under Input in Settings, can
reset any bind, or all of them at once.
If the whole keymap is missing from that list, the compositor config failed
to load. `Hyprland --verify-config` says why without touching your session.
## The screen resolution is wrong
Settings has a [Displays page](panama://settings/displays). It opens on a picture of what is connected —
one display or several — and everything under that picture belongs to
whichever one you have selected. Every change there reverts itself after
fifteen seconds unless you confirm it, so a mode your monitor cannot show
cannot strand you, and that covers color, bit depth and mirroring as well as
resolution, scale and rotation. If you are already stranded, `hyprctl
monitors` from a terminal shows what is applied.
## Something asked for a password and I do not know why
Panama's password prompt names the action that asked. If it was started by an
agent or a script through `panama-sudo`, it also shows a stated reason,
labelled as an unverified claim, beside the real action.
If you did not expect it at all, cancel. Nothing that needs privileges is
harmed by being refused.
## A button did nothing except change its wording
That is the button working. Anything in Settings that removes something takes
two presses: the first arms it, and the row then shows what is about to happen
beside a **Keep** that backs out. The second press is the one that does it.
Only one button in Settings is armed at a time, so arming a second one disarms
the first, wherever it was.
## The network I need is not in the list
The Wi-Fi list is what is in range. **Saved networks**, further down
[Connections](panama://settings/connectivity), is what this machine remembers,
including the ones nowhere near you — which is where to tidy up a profile you
want rid of, rather than waiting until you are standing next to it again. A
network that broadcasts no name is joined with **Join a hidden network…**, at
the bottom of the list.
Opening a connection shows what it actually got — address, gateway, DNS — and
is where you give it a fixed address instead when what it got does not work,
or mark it metered so updates and large downloads wait until you are somewhere
else.
## After an update, something is not right
`panama migrate` applies repairs this machine has not had yet. It is safe to
run at any time and does nothing when there is nothing to do.
If an update went badly, [Snapshots](panama://settings/snapshots) under System can roll
the system back. That tab is only there where btrfs snapshots are configured,
so if you cannot see it, this machine has none to roll back to.
## Reporting it
`panama doctor` produces a redacted summary suitable for pasting into an
issue. It deliberately omits tokens, hostnames and account details.
@@ -0,0 +1,161 @@
# Making it yours
## Settings
`Super + I`. Fourteen categories down the side, covering appearance, displays,
sound, input, network, power, accounts and the rest. A category with more than
one subject in it opens a row of tabs above the page, which is where the
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
of Input, and About, Software Update, Storage, Snapshots, Sync & Backup and
this manual are tabs of System.
Home is the first of them, and the page `Super + I` lands on. Its Overview tab
is the one you already know; My Home lists your Home Assistant lights by room
alongside the accessories Control Center shows, and Phone holds the iPhone
handoffs — battery, ring it, send it the clipboard or a file, and Messages.
The search box at the top searches the settings themselves rather than page
names, and each result says where it will land, so if you know what you want
to change you can type it and never touch the sidebar.
## Appearance
The Appearance page opens on **Themes**. At the top are light and dark, side by
side, each showing the theme it will land on; below them are two galleries —
six dark themes and four light ones, including Catppuccin, Nord, Gruvbox and
Everforest alongside Panama's own Tokyo Night Moon and Day.
Light and dark remember separately. Pick Everforest for the dark side and
Catppuccin Latte for the light one, and flipping between them lands on each
rather than resetting to the shipped pair.
Choosing a theme repaints the shell, the terminal, GTK applications, the lock
screen and the system monitor at once — not just the shell's own windows.
### Build your own
The **Theme editor** tab is four colors: primary, secondary, background and
foreground. Each has a swatch, a hex field you can type into, a color wheel
and an eyedropper for sampling anything on screen. Background and foreground
are not single colors — the panels, popovers, dividers and dimmed text are all
mixed from them, so moving the background moves the whole family with it.
Under that, a saturation slider for the whole palette at once, a fine-tune for
the exact hue of each end of the accent, and the effects — corners, blur,
shadows, glow and motion. Save it with a name and it appears in the galleries
above beside the shipped ones, effects included. Editing a shipped theme does
not damage it: it makes you a copy.
### Wallpapers
The wallpaper is per-monitor if you want it to be, and it can be a video.
Videos loop muted and decode on the GPU. They pause themselves while a game is
running, and on battery unless you say otherwise; while one is playing a pill
appears in the bar, and clicking it pauses immediately — for a call, a
recording, or just quiet. The lock screen shows a still frame from the video,
because it cannot play motion.
### Titlebars
There is no minimize button, on Panama's windows or on GNOME applications.
Hyprland has no minimize, so the button would do nothing; maximize means very
little in a tiler. You get close, on whichever side you prefer. Panama's own
titlebar can be turned off entirely, which leaves the Settings window bare:
`Super + Q` closes it, `Super + drag` moves it, `Escape` still works.
## Shell
Everything Panama draws on the screen has a tab under **Shell**: the bar, the
dock, Control Center, tiling and workspaces. Appearance decides the colors;
this decides what is there at all.
### The bar
The bar has no background of its own — it floats on your wallpaper. That is
fine over most images and hopeless over a few, so the **Bar** tab opens on the
three controls that fix it. **Bar text** follows your theme by default, or you
can force it light or dark for the wallpaper you actually use. **Bar text
shadow** puts a soft halo under every glyph at once. **Bar backdrop** fades a
thin scrim down from the top edge. All three land on the real bar above the
window as you change them, so you can watch rather than guess.
Under that, one switch per widget: weather, media, the clipboard button, the
calendar countdown, processor, memory, graphics, battery and agent usage. A
switch only ever removes something — turning weather on does not make a
forecast appear before one has been fetched. The indicators that come and go on
their own — system health, microphone and camera, focus, a paused video
wallpaper — have no switches, because they are already absent whenever they
have nothing to say.
Seconds and the weekday are here too. **24-hour time** is not: it also changes
the date menu, your notification timestamps and the lock screen, so it lives on
System Date & Time with the rest of the clock.
### The dock
**Pinned applications** shows the dock as the dock shows it. Drag an icon to
move it, hover one for the × that unpins it, and search underneath to add
something. The order here is the order on screen.
The dock itself takes the same gestures now. Drag an icon along the dock to
reorder it without opening Settings at all. Right-click one for its menu: every
open window of that application listed by title, whatever shortcuts the
application itself offers, then a new window, pin or unpin, quit, and **Dock
settings** if you want the rest. Scroll on an icon to step through that
application's windows. Hover one that has windows open and a preview of each
appears after a moment.
For pinning something that is not running, the launcher has **Add App to
Dock** — type it into `Super + Space`, pick the application, done.
### Control Center
The panel that opens from the status cluster at the right of the bar. The
**Control Center** tab turns its sections on and off: Focus, Home and Phone.
Hiding one only hides it from the panel; the settings page behind it still
works.
### Tiling and workspaces
The last two tabs hold what used to be spread across the old Desktop page.
**Tiling** is the window layout, the master area, gaps and window edges, and
the Hyprland notices Panama keeps quiet. **Workspaces** is the workspace
behaviour, how long a focus session runs, and your saved projects.
## Carrying settings between machines
System **[Sync & Backup](panama://settings/sync)**. Export writes your settings to a file; importing
one shows you exactly what would change before anything does, and you decide
then. Below it, **Settings backups** keeps dated copies you can restore from —
these are your settings, not btrfs Snapshots, which is a different tab and
covers the whole filesystem. **Restore defaults** is at the bottom: it resets
appearance, the dock and the applications pinned to it, clock, focus and
display policy, removes any themes you saved, and clears your Home accessory
arrangement. Your files, paired devices and settings backups are left alone.
## Applications
`panama apps` in a terminal offers the optional application categories the
installer asked about, so a machine can acquire Slack in March without having
wanted Discord in January.
`panama app` builds the handful of applications no repository carries. It is
deliberately not part of installing, because a source build is slow and
depends on an upstream that moves.
## Autostart and defaults
The Applications settings page controls which application opens which kind of
file, and what starts when you log in.
## Going further
The compositor is configured in Lua under `~/.config/hypr`, and every file
there explains what it does and why. `overrides.lua` is loaded last and is
the intended place for per-machine changes, so you can adjust anything
without editing a file the repository will update.
The shell is QML under `~/.config/quickshell`. Both directories are symlinks
into the Panama repository, so an edit is a change to your checkout and
`panama sync` will offer to commit it.
+2 -2
View File
@@ -1,9 +1,9 @@
// Exercises Migrations.applyWith against fixture steps and prints a verdict per // Exercises Migrations.applyWith against fixture steps and prints a verdict per
// case. Run by tests/quickshell/migrations-contract.sh. // case. Run by tests/quickshell/migrations-contract.
// //
// Fixture steps rather than the real list: the real one is empty until the // Fixture steps rather than the real list: the real one is empty until the
// first breaking schema change, and a mechanism that has never been run against // first breaking schema change, and a mechanism that has never been run against
// a failing step is not one to discover the behaviour of during an upgrade. // a failing step is not one to discover the behavior of during an upgrade.
import Quickshell import Quickshell
import QtQuick import QtQuick
@@ -36,7 +36,7 @@ Pill {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: PrivacyState.activeKinds.length > 1 visible: PrivacyState.activeKinds.length > 1
text: String(PrivacyState.activeKinds.length) text: String(PrivacyState.activeKinds.length)
color: Theme.fg color: Theme.barFg
font.family: Theme.fontFamily font.family: Theme.fontFamily
font.features: Theme.tabularFigures font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -15,7 +15,11 @@ PanelWindow {
color: "transparent" color: "transparent"
anchors.top: true anchors.top: true
anchors.right: true anchors.right: true
margins.top: Theme.barHeight + Theme.barGap * 2 // The gap ALONE, not the bar height plus the gap. exclusiveZone 0 means
// "reserve nothing, but respect what others reserved", so this surface
// already begins below the bar's zone -- adding the bar height here counted
// it twice and left the surface floating 48px under the bar instead of 12.
margins.top: Theme.barGap * 2
margins.right: Theme.barSideMargin margins.right: Theme.barSideMargin
exclusiveZone: 0 exclusiveZone: 0
implicitWidth: 350 implicitWidth: 350
@@ -25,10 +29,18 @@ PanelWindow {
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// Deliberately does not read Capture.recordingSeconds (or anything else
// that ticks once a second): this array is a plain JS array, not an
// identity-preserving model, so any dependency that changes every second
// would make the whole thing re-derive every second, and the Repeater
// below would destroy and recreate every row -- including one the user
// might be hovering or about to click. The set of activities should only
// change when an activity actually starts or stops. Elapsed time is
// rendered by each row's own Text binding instead, further down.
readonly property var activities: { readonly property var activities: {
const result = []; const result = [];
if (PrivacyState.recordingActive) if (PrivacyState.recordingActive)
result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama · " + root.elapsed(), tone: "danger", stoppable: true }); result.push({ kind: "recording", glyph: "\u{F044A}", label: "Screen recording", detail: "Panama", tone: "danger", stoppable: true });
if (PrivacyState.screenSharingActive) if (PrivacyState.screenSharingActive)
result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false }); result.push({ kind: "screen", glyph: "\u{F0379}", label: "Screen sharing", detail: PrivacyState.screenSharingApp || "Managed by the application", tone: "warn", stoppable: false });
if (PrivacyState.cameraActive) if (PrivacyState.cameraActive)
@@ -38,11 +50,12 @@ PanelWindow {
return result; return result;
} }
function elapsed(): string { // Pure formatter, no ticking property read here -- callers decide what
const total = Capture.recordingSeconds; // seconds value to pass, and only they take on the per-second dependency.
const seconds = String(total % 60).padStart(2, "0"); function formatElapsed(totalSeconds: int): string {
const minutes = Math.floor(total / 60) % 60; const seconds = String(totalSeconds % 60).padStart(2, "0");
const hours = Math.floor(total / 3600); const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`; return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
} }
@@ -164,7 +177,11 @@ PanelWindow {
Text { Text {
width: parent.width width: parent.width
text: activityRow.modelData.detail // Only this Text re-evaluates every second while
// recording -- Capture.recordingSeconds is read
// here, not in the parent `activities` array, so
// the row itself is never torn down for a tick.
text: activityRow.modelData.kind === "recording" ? activityRow.modelData.detail + " · " + root.formatElapsed(Capture.recordingSeconds) : activityRow.modelData.detail
color: Theme.fgDim color: Theme.fgDim
elide: Text.ElideRight elide: Text.ElideRight
font.family: Theme.fontFamily font.family: Theme.fontFamily
@@ -0,0 +1,592 @@
// The whole story behind the bar's one number.
//
// The bar shows the fullest window across every agent, because that is the one
// about to interrupt you. This is what that number is made of: each agent's
// limits with their reset times, what today cost, and where the tokens went.
//
// A Popover anchored under the widget, the way TrayMenu hangs off a tray icon —
// the house pattern for anything that belongs to a bar item rather than to the
// shell. Clicking outside closes it; so does Escape, which Popover's focus grab
// handles for every popover in the shell.
//
// Nothing in here animates on a timer. One 30-second tick advances the clock
// that "updated 4 minutes ago" and the reset countdowns read, and it only runs
// while the panel is open.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Popover {
id: root
implicitWidth: Theme.popoverWidth
implicitHeight: body.implicitHeight + contentPadding * 2
// Which agent's tab is showing. Empty means "whichever is first", so the
// panel is never blank because a collector was switched off between opens.
property string selectedId: ""
readonly property var agents: AgentUsage.readyRecords
readonly property var record: {
const list = root.agents;
if (list.length === 0)
return null;
for (const candidate of list)
if (candidate.id === root.selectedId)
return candidate;
return list[0];
}
// One clock for the whole panel, ticking only while it is open. Every
// elapsed-time and countdown string in here reads this instead of calling
// Date.now() in a binding, which would never invalidate.
property double nowMs: Date.now()
onVisibleChanged: {
if (root.visible) {
root.nowMs = Date.now();
// The local scans can be reused; the limits are what the panel is
// being opened to read.
AgentUsage.refreshLimits();
}
}
Timer {
interval: 30 * 1000
running: root.visible
repeat: true
onTriggered: root.nowMs = Date.now()
}
// ── Formatting ──────────────────────────────────────────────────────────
function tokenText(value: double): string {
const n = Number(value) || 0;
if (n >= 1e9)
return (n / 1e9).toFixed(1) + "B";
if (n >= 1e6)
return (n / 1e6).toFixed(1) + "M";
if (n >= 1e3)
return Math.round(n / 1e3) + "k";
return String(Math.round(n));
}
function percentText(fraction: real): string {
return Math.round(Math.min(1, Math.max(0, Number(fraction) || 0)) * 100) + "%";
}
function meterColor(fraction: real): color {
const percent = (Number(fraction) || 0) * 100;
if (percent >= 90)
return Theme.danger;
if (percent >= 75)
return Theme.warn;
return Theme.accent;
}
function parseTime(iso: string): double {
const parsed = Date.parse(String(iso ?? ""));
return isNaN(parsed) ? 0 : parsed;
}
// "Updated 4 minutes ago". A record with no timestamp says so rather than
// implying it is current.
function agoText(iso: string): string {
const at = root.parseTime(iso);
if (at <= 0)
return "Never collected";
const minutes = Math.floor(Math.max(0, root.nowMs - at) / 60000);
if (minutes < 1)
return "Updated just now";
if (minutes === 1)
return "Updated 1 minute ago";
if (minutes < 60)
return `Updated ${minutes} minutes ago`;
const hours = Math.floor(minutes / 60);
return hours === 1 ? "Updated 1 hour ago" : `Updated ${hours} hours ago`;
}
// "resets 2:40 pm" for something today, "resets Thu" for something further
// out. A window whose reset has passed says so rather than counting into
// the negative — the collector keeps a cached limit only until its window
// rolls over, so this is a record caught mid-rollover.
function resetText(iso: string): string {
const at = root.parseTime(iso);
if (at <= 0)
return "";
if (at <= root.nowMs)
return "resetting";
const when = new Date(at);
const sameDay = new Date(root.nowMs).toDateString() === when.toDateString();
if (sameDay)
return "resets " + when.toLocaleTimeString(Qt.locale(), "h:mm ap");
return "resets " + when.toLocaleDateString(Qt.locale(), "ddd");
}
function weekdayText(date: string): string {
const parts = String(date ?? "").split("-");
if (parts.length !== 3)
return "";
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
return when.toLocaleDateString(Qt.locale(), "ddd").slice(0, 2);
}
function isToday(date: string): bool {
const parts = String(date ?? "").split("-");
if (parts.length !== 3)
return false;
const when = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
return when.toDateString() === new Date(root.nowMs).toDateString();
}
// ── Derived views of the selected record ────────────────────────────────
readonly property var limits: {
const entries = root.record && Array.isArray(root.record.limits) ? root.record.limits : [];
return entries.filter(entry => entry && Number.isFinite(Number(entry.percent)));
}
readonly property var days: {
const entries = root.record && Array.isArray(root.record.recentDays) ? root.record.recentDays : [];
// recentDays.messageCount is a token total, despite the legacy name the
// collectors inherited.
return entries.map(day => ({
date: String(day?.date ?? ""),
tokens: Number(day?.messageCount) || 0
}));
}
readonly property double dayPeak: {
let peak = 0;
for (const day of root.days)
peak = Math.max(peak, day.tokens);
return peak;
}
// Top models by total tokens. Five rows is the whole point of the section:
// more than that and it stops being a glance.
readonly property var models: {
const usage = root.record?.modelUsage;
if (!usage || typeof usage !== "object")
return [];
const rows = [];
for (const name of Object.keys(usage)) {
const bucket = usage[name] || {};
const total = (Number(bucket.inputTokens) || 0)
+ (Number(bucket.outputTokens) || 0)
+ (Number(bucket.cacheReadInputTokens) || 0)
+ (Number(bucket.cacheCreationInputTokens) || 0);
if (total > 0)
rows.push({ name: name, tokens: total });
}
rows.sort((a, b) => b.tokens - a.tokens);
return rows.slice(0, 5);
}
readonly property double modelPeak: root.models.length > 0 ? root.models[0].tokens : 0
Column {
id: body
width: parent.width
spacing: 8
// ── Agent tabs ──────────────────────────────────────────────────────
//
// Only worth drawing when there is a choice to make.
Row {
width: parent.width
spacing: 6
visible: root.agents.length > 1
Repeater {
model: root.agents
delegate: Rectangle {
id: tab
required property var modelData
readonly property bool current: root.record && root.record.id === tab.modelData.id
width: (body.width - 6 * (root.agents.length - 1)) / root.agents.length
height: 28
radius: 9
border.width: tab.current ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.3)
color: tab.current
? Theme.alpha(Theme.accent, 0.14)
: (tabMouse.containsMouse ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.fg, 0.05))
Text {
anchors.centerIn: parent
text: tab.modelData.name || tab.modelData.id
color: tab.current ? Theme.fg : Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
MouseArea {
id: tabMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.selectedId = String(tab.modelData.id ?? "")
}
}
}
}
// ── Who, and how current ────────────────────────────────────────────
Item {
width: parent.width
implicitHeight: Math.max(heroGlyph.implicitHeight, heroName.implicitHeight, tierChip.implicitHeight)
Text {
id: heroGlyph
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "\u{F1719}" // md-robot-outline
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: Theme.fontSizeLarge
}
Text {
id: heroName
anchors.left: heroGlyph.right
anchors.leftMargin: 9
anchors.right: tierChip.visible ? tierChip.left : parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: root.record?.name ?? "Agent usage"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
}
// The plan, when the collector could name one. It is the only thing
// from the credential store allowed into a record.
Rectangle {
id: tierChip
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: String(root.record?.tierLabel ?? "") !== ""
implicitWidth: tierText.implicitWidth + 18
implicitHeight: tierText.implicitHeight + 6
radius: Theme.pillRadius
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.25)
color: Theme.alpha(Theme.accent, 0.1)
Text {
id: tierText
anchors.centerIn: parent
text: String(root.record?.tierLabel ?? "").toUpperCase()
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
font.weight: Font.DemiBold
}
}
}
Text {
width: parent.width
text: root.agoText(root.record?.updatedAt)
color: Theme.fgMuted
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
// ── An honest word when the numbers are not authoritative ───────────
Rectangle {
width: parent.width
visible: String(root.record?.usageStatusText ?? "") !== ""
implicitHeight: statusColumn.implicitHeight + 18
radius: Theme.cardRadius
border.width: 0
color: Theme.alpha(Theme.warn, 0.12)
Column {
id: statusColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 10
anchors.rightMargin: 10
spacing: 3
Text {
width: parent.width
text: root.record?.usageStatusText ?? ""
color: Theme.warn
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
width: parent.width
visible: String(root.record?.authHelpText ?? "") !== ""
text: root.record?.authHelpText ?? ""
color: Theme.fgDim
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
// ── Limits ──────────────────────────────────────────────────────────
//
// Model-scoped windows sit in the same list as the flat ones: the
// collector settles which window an entry belongs to and titles it, so
// "Fable Weekly" reads beside "Weekly (7-day)" rather than under it.
Repeater {
model: root.limits
delegate: Column {
id: limitRow
required property var modelData
width: body.width
topPadding: 5
spacing: 6
Item {
width: parent.width
implicitHeight: limitLabel.implicitHeight
Text {
id: limitLabel
anchors.left: parent.left
anchors.right: limitValue.left
anchors.rightMargin: 8
text: limitRow.modelData.label ?? "Limit"
color: Theme.fg
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
id: limitValue
anchors.right: parent.right
anchors.baseline: limitLabel.baseline
text: {
const reset = root.resetText(limitRow.modelData.resetsAt);
const percent = root.percentText(limitRow.modelData.percent);
return reset ? reset + " · " + percent : percent;
}
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
}
}
Rectangle {
width: parent.width
height: 8
radius: 4
border.width: 0
color: Theme.alpha(Theme.fg, 0.08)
Rectangle {
width: Math.max(0, Math.min(1, Number(limitRow.modelData.percent) || 0)) * parent.width
height: parent.height
radius: parent.radius
border.width: 0
color: root.meterColor(limitRow.modelData.percent)
}
}
}
}
Rectangle {
width: parent.width
visible: root.days.length > 0 || root.models.length > 0
height: 1
color: Theme.alpha(Theme.fg, 0.08)
}
// ── Tokens, last seven days ─────────────────────────────────────────
Item {
width: parent.width
visible: root.dayPeak > 0
implicitHeight: dayHeader.implicitHeight + 8 + 56
Text {
id: dayHeader
anchors.left: parent.left
text: "Tokens, last 7 days"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Text {
anchors.right: parent.right
anchors.baseline: dayHeader.baseline
text: "today " + root.tokenText(root.record?.todayTotalTokens ?? 0)
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
}
Row {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 56
spacing: 6
Repeater {
model: root.days
delegate: Column {
id: dayColumn
required property var modelData
readonly property bool today: root.isToday(dayColumn.modelData.date)
width: (body.width - 6 * 6) / 7
spacing: 4
Item {
width: parent.width
height: 56 - 4 - dayLabel.implicitHeight
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(
dayColumn.modelData.tokens > 0 ? 2 : 0,
root.dayPeak > 0 ? (dayColumn.modelData.tokens / root.dayPeak) * parent.height : 0)
radius: 4
border.width: 0
color: dayColumn.today ? Theme.accent : Theme.alpha(Theme.accent, 0.35)
}
}
Text {
id: dayLabel
width: parent.width
text: root.weekdayText(dayColumn.modelData.date)
color: dayColumn.today ? Theme.fgDim : Theme.fgMuted
horizontalAlignment: Text.AlignHCenter
font.family: Theme.fontFamily
font.pixelSize: Math.max(8, Theme.fontSizeSmall - 2)
font.weight: dayColumn.today ? Font.DemiBold : Font.Normal
}
}
}
}
}
// ── Where the tokens went ───────────────────────────────────────────
Text {
width: parent.width
visible: root.models.length > 0
topPadding: 4
text: "By model"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Repeater {
model: root.models
delegate: Item {
id: modelRow
required property var modelData
width: body.width
implicitHeight: 18
Text {
id: modelName
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: 108
text: modelRow.modelData.name
color: Theme.fgDim
elide: Text.ElideRight
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
}
Item {
anchors.left: modelName.right
anchors.leftMargin: 10
anchors.right: modelValue.left
anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
height: 6
Rectangle {
width: root.modelPeak > 0
? Math.max(2, (modelRow.modelData.tokens / root.modelPeak) * parent.width)
: 0
height: parent.height
radius: 3
border.width: 0
color: Theme.alpha(Theme.accentAlt, 0.55)
}
}
Text {
id: modelValue
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
width: 52
text: root.tokenText(modelRow.modelData.tokens)
color: Theme.fgDim
horizontalAlignment: Text.AlignRight
font.family: Theme.fontFamily
font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1)
font.features: Theme.tabularFigures
}
}
}
// ── Sessions, when that is all there is ─────────────────────────────
Text {
width: parent.width
visible: root.dayPeak <= 0 && root.models.length === 0 && root.record !== null
text: `Sessions today: ${root.record?.todaySessions ?? 0}`
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
Text {
width: parent.width
visible: root.record === null
text: "No collector has anything to report yet."
color: Theme.fgDim
wrapMode: Text.WordWrap
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
}
@@ -0,0 +1,68 @@
// How much of the busiest agent subscription is gone, beside the other vitals.
//
// One number: whichever window across every collected agent is closest to its
// limit, because that is the one about to interrupt you.
//
// Hidden unless asked for AND a collector has real numbers. A bar indicator
// reading "unknown" is worse than an empty space, and this is off by default:
// it is a coding-tool readout, not something a general-purpose desktop shows
// without being asked.
//
// The glyph and the number live in their own Row, the same shape VitalsField
// uses. Anchoring them straight into Pill's layout Row instead made each child
// centre against a Row whose height those same children determine, and they
// came out sitting off-centre against the rest of the bar.
//
// Clickable, because a readout you cannot ask anything of is furniture. Left
// click opens the panel behind the number; right click opens the settings that
// govern it.
import QtQuick
import qs.config
import qs.services
import qs.widgets
Pill {
id: root
visible: Settings.showAgentUsage && AgentUsage.available
onActivated: panel.visible = !panel.visible
onSecondaryActivated: ShellState.openSettings("bar")
Row {
spacing: 4
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\u{F1719}" // md-robot-outline
color: {
if (AgentUsage.headline >= 90) return Theme.danger;
if (AgentUsage.headline >= 75) return Theme.warn;
return Theme.barFgDim;
}
font.family: Theme.fontMono
font.pixelSize: Theme.fontSize
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: AgentUsage.headline + "%"
color: Theme.barFg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.features: Theme.tabularFigures
// Fixed and right-aligned so the bar does not shuffle sideways when
// the number gains a digit, exactly as VitalsField does.
horizontalAlignment: Text.AlignRight
width: 30
}
}
// Hangs off this pill the way TrayMenu hangs off a tray icon. A PopupWindow
// is not an Item, so it takes no space in Pill's layout Row.
AgentUsagePanel {
id: panel
anchorItem: root
}
}

Some files were not shown because too many files have changed in this diff Show More