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
Panama :: Hyprland
A Hyprland desktop built to reproduce the GNOME + Forge setup it replaces, so that muscle memory transfers unchanged.
The one thing to know first
Hyprland 0.55 replaced hyprlang with Lua. hyprland.conf still loads on
0.56 as a legacy fallback and is removed in 0.57. Everything here is Lua.
The authoritative option reference for the installed build is on disk:
| File | What it is |
|---|---|
/usr/share/hypr/stubs/hl.meta.lua |
Generated type stub — the exact option list for this build |
/usr/share/hypr/hyprland.lua |
Upstream example config |
Wire the stub into your editor with .luarc.json in this directory for
autocomplete.
The Hypr ecosystem tools did NOT move to Lua. hyprlock.conf,
hypridle.conf, hyprpaper.conf and hyprtoolkit.conf are still hyprlang.
Don't "fix" them.
Layout
| File | Contents |
|---|---|
hyprland.lua |
Entry point. Each require() is its own error scope |
prefs.lua |
Reads the settings file the Settings app writes. See below |
env.lua |
Environment. Note the uwsm caveat below |
monitors.lua |
Monitor geometry and scaling (the Kuycon by description), and the HDR decision |
looks.lua |
Colors, blur, glow, shadows, animations, VRR, scanout |
input.lua |
Keyboard/mouse. Click-to-focus, like GNOME |
rules.lua |
Window rules, gaming rules, layer rules for the shell |
keybinds.lua |
The full keymap |
autostart.lua |
Session startup |
overrides.lua |
Per-machine escape hatch, loaded last |
hyprlock.conf / hypridle.conf / hyprpaper.conf / hyprtoolkit.conf |
Ecosystem tools (hyprlang) |
Validate any change without leaving your session:
Hyprland --verify-config
Generated configuration
Two ecosystem tools cannot read the shared settings file, and their configs live in this directory — which is a symlink into the Panama repository, so writing to them at runtime would put machine state into a tracked file. Both are therefore generated elsewhere:
| Tool | Generated to | Pointed there by |
|---|---|---|
hypridle |
$XDG_STATE_HOME/panama/hypridle.conf |
a systemd user drop-in installed by panama-idle install |
hyprpaper |
not generated — the wallpaper is applied over IPC and re-applied at shell start | — |
quickshell/scripts/panama-idle regenerates the hypridle config from the
settings store and restarts the daemon. hypridle.conf in this directory
remains the shipped default and is what runs when the drop-in is not installed;
The Settings app shows which of the two states you are in rather than presenting
controls that quietly do nothing.
Remove the drop-in and go back to the shipped config with:
~/.config/quickshell/scripts/panama-idle remove
Settings: one file, both sides
~/.config/panama/settings.json is shared with the Quickshell side. The
relationship is:
- This config is the default. Every adjustable value is written
prefs.get("key", <shipped value>), so the config still works standalone with no settings file at all. - The JSON is the truth. Hyprland and Quickshell both read it.
- The Settings app is the editor. It writes the file and applies the change live, so nothing needs a reload and the two sides cannot drift apart.
To add an adjustable setting: add an entry to
quickshell/config/PreferenceSchema.qml with a hypr block naming the
hl.config path, then read it here with prefs.get. Nothing else is needed —
persistence, validation, reset, and the live write are all derived from that
entry.
prefs.lua never raises. A missing, empty, truncated, malformed, or
wrong-typed settings file costs you your customizations and nothing else;
tests/hypr/prefs-fallback-contract pins that, including that Hyprland still
accepts the config in each of those states.
Adding a keybind: use bind, not hl.bind
Every bind in keybinds.lua goes through a local bind() wrapper that
substitutes the chord from a stored override, so shortcuts can be moved from
the Settings app without editing this file.
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
Only the chord is ever taken from settings — the action is always the Lua value written here. A stored override can therefore move a shortcut but can never make one do something else, which is what makes reading overrides from a file the user can edit safe.
Overrides are keyed by the shipped chord, not the description. Descriptions
are not unique: "Calculator" is both SUPER + C and the XF86Calculator
hardware key, and keying by description moved both onto the same new chord,
silently costing the hardware key.
An override whose value is not a plausible chord is ignored in favour of the
shipped one, so a hand-edited settings.json cannot cost you a keymap.
Keybind descriptions are required
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
one has nothing readable beside its chord, and the Settings app drops it from the
Input & Shortcuts page rather than showing a mystery row.
tests/quickshell/keybinds-contract fails if any bind lacks a description, so
this cannot regress silently.
hl.bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
The page groups shortcuts by what the description says they do, so a new bind lands in the right section with no change to the UI.
Never use hyprctl keyword
On a Lua-configured Hyprland it refuses the write, prints
keyword can't work with non-legacy parsers to stdout, and still exits 0:
$ hyprctl getoption decoration:rounding -j # → "int": 18
$ hyprctl keyword decoration:rounding 4 # → the refusal above
$ echo $? # → 0
$ hyprctl getoption decoration:rounding -j # → "int": 18, unchanged
Use hyprctl eval 'hl.config({ ... })' instead. Note that eval also exits 0
on syntax and runtime errors, reporting them as an error: line on stdout — so
for either command, the only trustworthy signal that a write landed is reading
the value back with hyprctl getoption.
The look
Tokyo Night Moon, with two accents that come from the tmux theme:
| Token | Value | Role |
|---|---|---|
accent |
#82aaff |
Carries every state meaning — focused, active, on |
accentSecondary |
#b172b0 |
Never used alone; only the far end of a gradient |
The signature is the prism: the two accents meeting. It appears in exactly four places, and nowhere else —
- A one-pixel hairline along the top edge of every glass surface (the bar,
dock, popovers), running blue on the left to orchid on the right and fading
out before the corners. See
quickshell/widgets/PrismEdge.qml. - The focused window's border —
general.col.active_border, blue→orchid at 115°. Unfocused windows get no color at all, because the gradient only means something if one window on screen is wearing it. - The active workspace pill in the bar.
- Slider fills.
The restraint is the point. The pink stops being special the moment it's used as a flat fill, so it never is.
Typography
Adwaita Sans for everything the user reads as text — no monospace in the UI. A monospaced clock or percentage reads as terminal output pasted into a panel, which is the opposite of the intent.
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
hardcoded #2e3436 fill that Qt (unlike GTK) will not recolor. Where a
themed freedesktop icon is wanted instead, quickshell/widgets/ThemedIcon.qml
paints a palette color through the icon's alpha.
Anything whose digits change in place — the clock, the vitals percentages, the
recording timer, the selection readout — sets font.features: Theme.tabularFigures. Tabular figures share one advance width, so the text
stops twitching as numbers tick without reaching for a monospaced face.
Window rounding is 18, matching the shell's popover radius, so a window and a panel next to each other read as the same family of object.
On animated gradient borders: Hyprland can rotate the border gradient
continuously with borderangle + style = "loop". Don't. The wiki is explicit
that it forces a full-refresh-rate repaint forever, even when no border is
visible — at 4500×3000 that is pure idle GPU burn. This config uses
style = "once", so the gradient sweeps into place when a window takes focus
and then costs nothing.
Session: use the uwsm one
Log in as "Hyprland (uwsm-managed)", not plain "Hyprland".
xdg-desktop-portal-hyprland's systemd unit requires graphical-session.target,
and Fedora ships no hyprland-session.target. Without uwsm that target never
activates and screen sharing in OBS, Sunshine and Zoom silently fails.
The uwsm environment gotcha
hl.env() in env.lua exports into the compositor's environment, not the
systemd user manager. Anything started as a user unit — vicinae.service,
hyprpaper.service, hyprpolkitagent.service, hypridle.service — and every
app those launch will not see it.
That is why ~/.config/uwsm/env and ~/.config/uwsm/env-hyprland exist and
duplicate the important variables. Keep them in sync with env.lua; env.lua
remains the fallback for the plain session.
HDR — read this before turning it on
GNOME ran this panel in bt2100/HDR. This config deliberately does not.
On Hyprland 0.56.x, cm = "hdr" currently breaks screencopy: grim
screenshots come back empty, and OBS/Sunshine capture and hyprlock's blurred
background go with them. Root cause is still open upstream.
So the desktop runs SDR at 10-bit (cm = "auto") and HDR is handed to
fullscreen games only, via render.cm_auto_hdr = 1 in looks.lua. Games get
HDR; screenshots keep working.
To try full-time HDR anyway, overrides.lua has the block and the ordered list
of workarounds to reach for when things break.
Keymap
The mental model is unchanged from Forge:
- SUPER acts on windows
- ALT acts on workspaces
- SUPER + CTRL changes layout structure
Windows
| Key | Action |
|---|---|
SUPER + H/J/K/L (or arrows) |
Focus |
SUPER + SHIFT + H/J/K/L |
Move window |
SUPER + CTRL + H/J/K/L |
Swap window |
SUPER + SHIFT + Y/O · B/M |
Wider · narrower |
SUPER + SHIFT + I/U · ,/N |
Taller · shorter |
SUPER + [ / ] / = |
Shrink / expand / reset split |
SUPER + Q |
Close |
SUPER + U |
Fullscreen |
SUPER + CTRL + C |
Toggle float |
SUPER + CTRL + SHIFT + C |
Pin (nearest thing to Forge's "always float") |
SUPER + CTRL + G / Z / V |
Toggle split / preselect right / preselect down |
SUPER + Tab / SHIFT + Tab |
Cycle windows |
SUPER + SHIFT + grave |
Last window |
SUPER + X / SUPER + SHIFT + X |
Restore scratchpad / minimize to scratchpad |
Workspaces (dynamic, like GNOME)
| Key | Action |
|---|---|
ALT + H / ALT + L |
Previous / next workspace |
ALT + SHIFT + H/L |
Move window to previous / next |
ALT + 1..9, ALT + 0 |
Jump to workspace |
ALT + SHIFT + 1..9 |
Send window to workspace |
SUPER + scroll |
Change workspace |
Launcher and shell
| Key | Action |
|---|---|
SUPER + A / R / Space |
Launcher (vicinae) — all three, pick your favorite |
SUPER + SHIFT + R |
Fallback launcher (wofi) if the shell is broken |
SUPER + V |
Clipboard history |
SUPER + . |
Emoji picker |
SUPER + S |
Quick settings |
SUPER + I |
Settings |
SUPER + SHIFT + F |
Start or reveal focus session |
SUPER + B |
Notification center |
SUPER + grave |
Workspace overview |
Print |
Screenshot / record picker |
SHIFT / ALT + Print |
Screenshot screen / window immediately |
SUPER + SHIFT + S |
Screen Intelligence — read text and codes from a selection |
SUPER + SHIFT + P |
Color picker |
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 |
Apps
SUPER + T terminal · N neovim · W browser · F files · C calculator ·
E mail · I Settings · CTRL + SHIFT + Esc system monitor. GNOME
Settings remains searchable in Vicinae for hardware and account panels.
Calendar and notifications
Click the clock to open the two-column Daybook on Agenda. The month grid, today's schedule, Up Next, weather, and conditional media controls stay visible together. Its right pane has three first-class pages:
- Agenda — the selected day's calendar.
- Ongoing — Focus, Caffeine, recording, screen sharing, camera, and microphone activity, visible until each state ends.
- Notifications — grouped history, Do Not Disturb, and clear actions.
SUPER + B opens Notifications directly. Switching pages preserves the
selected date and visible month. Brief completed events such as screenshots and
device changes remain in Signal Glass instead of accumulating in Ongoing.
Calendar data comes from Evolution Data Server, so the Google, iCloud, Nextcloud, and local calendars already configured for GNOME remain the source of truth. Panama never stores account credentials. Use GNOME Calendar or Online Accounts to add and manage accounts, and click an event in Daybook to hand off editing and full detail to GNOME Calendar.
The right side of the bar shows a small calendar capsule only from 15 minutes
before a timed event until 5 minutes after it starts. It displays relative time
(12m or Now); the event title is available on hover but otherwise stays out
of the bar. All-day events never trigger the capsule.
Control Center
Click the right-side status cluster or press SUPER + S for Panama's Control
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
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
dashboard windows.
Home shows the first four selected favorites at rest and every selected light when expanded. Use Settings → Home & Phone to choose favorites, set Panama-only aliases, and arrange their order. Dragging a brightness control only previews the value; releasing it sends one brightness request. A normal power toggle leaves Home Assistant responsible for restoring its previous level.
Credentials stay private in the gitignored config/bash/env file, with the
existing GNOME extension and Secret Service setup retained as a compatibility
fallback. Favorites, aliases, and order live in Quickshell state. No shell
restart is required after changing credentials; close and reopen Control Center
to refresh. If Home Assistant is offline, the last known values stay visible
with a stale-state label and Retry action.
Phone uses KDE Connect for the capabilities the paired iPhone actually advertises: Send File, Send Clipboard, and Ring. The device remains visible while iOS suspends KDE Connect, but those actions stay disabled until it reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery percentage is invented when iOS reports none. Active file sends appear in Daybook's Ongoing page; successful sends become a quiet recent exchange.
Screen Intelligence
SUPER + SHIFT + S opens the capture picker directly in Selection + Read
mode. Read is also available beside Screenshot and Record under Print, and
works on a whole display, a window, or a region. Tesseract recognizes English
text locally; ZBar recognizes QR codes and barcodes. The result sheet can copy
text, search it, translate it, or open a detected web address. No pixels leave
the workstation unless one of those explicit network actions is selected.
Deliberate deviations from GNOME
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 axis and lets the layout pick the edge. The eight Forge keys collapse to four behaviors, keeping the horizontal/vertical and grow/shrink pairing.
- Overview on
SUPER + grave, not a bare SUPER tap. Tap-detection on a modifier misfires when you're quick with SUPER combos. - Lock on
CTRL + ALT + LorSUPER + Backspace.SUPER + Lis "focus right" in this keymap. SUPER + gravewas Forge's "cycle windows of same app", which Hyprland has no equivalent for. "Last window" moved toSUPER + SHIFT + grave.gnome-control-centeris launched withXDG_CURRENT_DESKTOP=GNOME, because it hard-refuses to start otherwise. Panels backed by system services work; panels backed by GNOME Shell (Displays, Keyboard Shortcuts, Multitasking, Appearance) are inert — Hyprland owns those now.
Gaming
content = "game" on the window rules in rules.lua is the keystone — it is
what misc.vrr = 3, render.direct_scanout = 2 and cursor.no_break_fs_vrr = 2
key off. Games additionally get blur, animation, shadow and dim disabled, plus
immediate for tearing.
Tearing only takes effect when the game is fullscreen and the only thing on screen — no bar, no notifications.
Do not install a notification daemon
mako, dunst and SwayNotificationCenter all register
Name=org.freedesktop.Notifications for D-Bus activation. Installing any of
them creates a startup race against Quickshell's notification server, and
whoever wins keeps the name. If one gets installed as a dependency,
systemctl --user mask <name>.service.
Troubleshooting
Nothing autostarts — check systemctl --user status hyprland-session.target
and systemctl --user show-environment | grep WAYLAND_DISPLAY. An empty
environment means uwsm didn't export it and units with
ConditionEnvironment=WAYLAND_DISPLAY silently skip.
Screen sharing missing — busctl --user list | grep impl.portal should show
xdg-desktop-portal-hyprland.
Qt apps look wrong — QT_QPA_PLATFORMTHEME must be gtk3. It is a single
value, not a list: Qt splits on : and uses only the first token.
Wrong GPU / won't start — AQ_DRM_DEVICES points at
/dev/dri/amd-dgpu, created by config/copy/etc/udev/rules.d/99-panama-gpu.rules.
Both env.lua and uwsm/env-hyprland guard on the symlink existing, so a
missing rule degrades to "let aquamarine choose" rather than failing to start.