diff --git a/.gitignore b/.gitignore
index a041459..a2d024d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,4 @@ __pycache__/
/config/dot/gtk-3.0/settings.ini
/config/dot/gtk-4.0/settings.ini
/config/dot/tmux/current-theme.conf
+/config/dot/hypr/hyprlock.conf
diff --git a/config/dot/hypr/hyprlock.conf b/config/dot/hypr/hyprlock.conf.template
similarity index 74%
rename from config/dot/hypr/hyprlock.conf
rename to config/dot/hypr/hyprlock.conf.template
index f662b64..2d84113 100644
--- a/config/dot/hypr/hyprlock.conf
+++ b/config/dot/hypr/hyprlock.conf.template
@@ -1,8 +1,17 @@
# ─────────────────────────────────────────────────────────────────────────────
-# hyprlock — lock screen
+# hyprlock — lock screen.
#
-# Tokyo Night Moon, matching quickshell/config/Theme.qml:
-# accent #82aaff fg #c8d3f5 dim #828bb8 bg #222436 red #ff757f
+# GENERATED FILE. Edit hyprlock.conf.template and re-run
+# quickshell/scripts/panama-theme-apps; editing this copy is overwritten on the
+# next colour scheme change.
+#
+# The colours 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
+# stayed dark when everything else went light.
+#
+# hyprlock takes rgba(r, g, b, a) in DECIMAL rather than hex, which is why the
+# template carries "R, G, B" triples where the rest of Panama uses hex. The two
+# Pango markup values are the exception and want ##rrggbb.
#
# hyprlang syntax, not Lua — hyprlock is a separate project from Hyprland.
# ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +54,7 @@ background {
vibrancy_darkness = 0.05
# Shown if the screenshot is unavailable.
- color = rgba(34, 36, 54, 1.0)
+ color = rgba(@BG@, 1.0)
zindex = -1
}
@@ -54,7 +63,7 @@ background {
label {
monitor =
text = cmd[update:1000] date +"%-I:%M"
- color = rgba(200, 211, 245, 1.0)
+ color = rgba(@FG@, 1.0)
font_size = 120
font_family = Adwaita Sans Light
position = 0, 260
@@ -65,7 +74,7 @@ label {
label {
monitor =
text = cmd[update:60000] date +"%A, %B %-d"
- color = rgba(130, 139, 184, 1.0)
+ color = rgba(@MUTED@, 1.0)
font_size = 24
font_family = Adwaita Sans
position = 0, 160
@@ -84,18 +93,18 @@ input-field {
outline_thickness = 2
rounding = 26
- outer_color = rgba(130, 170, 255, 0.9)
- inner_color = rgba(46, 47, 61, 0.85)
- font_color = rgba(200, 211, 245, 1.0)
- check_color = rgba(130, 170, 255, 1.0)
- fail_color = rgba(255, 117, 127, 1.0)
+ outer_color = rgba(@ACCENT@, 0.9)
+ inner_color = rgba(@FIELD@, 0.85)
+ font_color = rgba(@FG@, 1.0)
+ check_color = rgba(@ACCENT@, 1.0)
+ fail_color = rgba(@ERROR@, 1.0)
dots_size = 0.25
dots_spacing = 0.3
dots_center = true
- placeholder_text = Password
- fail_text = $FAIL ($ATTEMPTS)
+ placeholder_text = Password
+ fail_text = $FAIL ($ATTEMPTS)
fade_on_empty = false
hide_input = false
@@ -105,7 +114,7 @@ input-field {
label {
monitor =
text = $USER
- color = rgba(200, 211, 245, 0.9)
+ color = rgba(@FG@, 0.9)
font_size = 16
font_family = Adwaita Sans
position = 0, -110
diff --git a/config/dot/quickshell/scripts/panama-theme-apps b/config/dot/quickshell/scripts/panama-theme-apps
index 23be064..a6fc474 100755
--- a/config/dot/quickshell/scripts/panama-theme-apps
+++ b/config/dot/quickshell/scripts/panama-theme-apps
@@ -31,6 +31,59 @@ case "$scheme" in
*) printf 'usage: panama-theme-apps [dark|light]\n' >&2; exit 2 ;;
esac
+# ── hyprlock ─────────────────────────────────────────────────────────────────
+# The lock screen. hyprlock is launched fresh on every lock (`pidof hyprlock ||
+# hyprlock`), so it reads this file each time and needs no restart.
+#
+# It takes rgba(r, g, b, a) in DECIMAL, not hex, so the palette is expressed as
+# "R, G, B" triples here rather than the hex used everywhere else. The two
+# _HEX values are the exception: they sit inside Pango markup, where hyprlock
+# wants ##rrggbb.
+lock_dir="${XDG_CONFIG_HOME:-$HOME/.config}/hypr"
+lock_template="$lock_dir/hyprlock.conf.template"
+
+if [[ "$scheme" == "light" ]]; then
+ lock_fg="55, 96, 191" # #3760bf
+ lock_muted="97, 114, 176" # #6172b0
+ lock_accent="46, 125, 233" # #2e7de9
+ lock_error="245, 42, 101" # #f52a65
+ lock_bg="225, 226, 231" # #e1e2e7
+ lock_field="208, 213, 227" # #d0d5e3
+ lock_muted_hex="6172b0"
+ lock_error_hex="f52a65"
+else
+ lock_fg="200, 211, 245" # #c8d3f5
+ lock_muted="130, 139, 184" # #828bb8
+ lock_accent="130, 170, 255" # #82aaff
+ lock_error="255, 117, 127" # #ff757f
+ lock_bg="34, 36, 54" # #222436
+ lock_field="46, 47, 61" # #2e2f3d
+ lock_muted_hex="828bb8"
+ lock_error_hex="ff757f"
+fi
+
+status_hyprlock="skipped"
+if [[ -r "$lock_template" ]]; then
+ # Written atomically: a lock triggered mid-write would otherwise read a
+ # truncated config and fall back to hyprlock's own defaults, which is a
+ # bright grey screen with none of this desktop's identity.
+ if sed -e "s/@FG@/$lock_fg/g" \
+ -e "s/@MUTED@/$lock_muted/g" \
+ -e "s/@ACCENT@/$lock_accent/g" \
+ -e "s/@ERROR@/$lock_error/g" \
+ -e "s/@BG@/$lock_bg/g" \
+ -e "s/@FIELD@/$lock_field/g" \
+ -e "s/@MUTED_HEX@/$lock_muted_hex/g" \
+ -e "s/@ERROR_HEX@/$lock_error_hex/g" \
+ "$lock_template" >"$lock_dir/hyprlock.conf.tmp" 2>/dev/null \
+ && mv "$lock_dir/hyprlock.conf.tmp" "$lock_dir/hyprlock.conf" 2>/dev/null; then
+ status_hyprlock="written"
+ else
+ rm -f "$lock_dir/hyprlock.conf.tmp"
+ status_hyprlock="failed"
+ fi
+fi
+
# ── tmux ─────────────────────────────────────────────────────────────────────
# Generated like kitty's: tmux.conf sources current-theme.conf, and that file is
# machine state rather than configuration. Running servers are re-sourced so an
@@ -146,4 +199,5 @@ jq -cn \
--arg gtk "$status_gtk" \
--arg btop "$status_btop" \
--arg tmux "$status_tmux" \
- '{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux}'
+ --arg hyprlock "$status_hyprlock" \
+ '{scheme: $scheme, kitty: $kitty, gtk: $gtk, btop: $btop, tmux: $tmux, hyprlock: $hyprlock}'
diff --git a/setup/scripts/link-dotfiles b/setup/scripts/link-dotfiles
index ad29e48..a562f38 100755
--- a/setup/scripts/link-dotfiles
+++ b/setup/scripts/link-dotfiles
@@ -89,6 +89,38 @@ elif [ -d "$PANAMA_DOT/tmux/themes" ]; then
log "Seeded tmux $tmux_scheme theme ($tmux_name) → $TMUX_THEME"
fi
+# hyprlock.conf is generated from a template on every colour scheme change and
+# is not committed. Seed it so the FIRST lock of a fresh install is themed --
+# without it hyprlock falls back to its own defaults, which is a bare grey
+# screen with none of this desktop's identity, and the first time anyone would
+# find out is when they walked away from the machine.
+HYPRLOCK_TEMPLATE="$PANAMA_DOT/hypr/hyprlock.conf.template"
+HYPRLOCK_CONF="$PANAMA_DOT/hypr/hyprlock.conf"
+if [ -e "$HYPRLOCK_CONF" ]; then
+ log "Keeping existing hyprlock config at $HYPRLOCK_CONF"
+elif [ -r "$HYPRLOCK_TEMPLATE" ]; then
+ lock_scheme="dark"
+ lock_prefs="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
+ if [ -r "$lock_prefs" ]; then
+ lock_stored="$(jq -r '.colorScheme // "dark"' "$lock_prefs" 2>/dev/null || echo dark)"
+ [ "$lock_stored" = "light" ] && lock_scheme="light"
+ fi
+ if [ "$lock_scheme" = "light" ]; then
+ sed -e "s/@FG@/55, 96, 191/g" -e "s/@MUTED@/97, 114, 176/g" \
+ -e "s/@ACCENT@/46, 125, 233/g" -e "s/@ERROR@/245, 42, 101/g" \
+ -e "s/@BG@/225, 226, 231/g" -e "s/@FIELD@/208, 213, 227/g" \
+ -e "s/@MUTED_HEX@/6172b0/g" -e "s/@ERROR_HEX@/f52a65/g" \
+ "$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
+ else
+ sed -e "s/@FG@/200, 211, 245/g" -e "s/@MUTED@/130, 139, 184/g" \
+ -e "s/@ACCENT@/130, 170, 255/g" -e "s/@ERROR@/255, 117, 127/g" \
+ -e "s/@BG@/34, 36, 54/g" -e "s/@FIELD@/46, 47, 61/g" \
+ -e "s/@MUTED_HEX@/828bb8/g" -e "s/@ERROR_HEX@/ff757f/g" \
+ "$HYPRLOCK_TEMPLATE" > "$HYPRLOCK_CONF"
+ fi
+ log "Seeded hyprlock $lock_scheme theme → $HYPRLOCK_CONF"
+fi
+
# btop reads themes from its own config directory, but OWNS btop.conf -- it
# rewrites that file on exit -- so only the theme files are exposed, per file,
# and the config itself is left to btop. panama-theme-apps edits the single
diff --git a/tests/quickshell/lock-screen-theme-contract.sh b/tests/quickshell/lock-screen-theme-contract.sh
new file mode 100755
index 0000000..e5afc8a
--- /dev/null
+++ b/tests/quickshell/lock-screen-theme-contract.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+
+# The lock screen follows the colour scheme.
+#
+# It did not. hyprlock.conf shipped with Tokyo Night Moon hardcoded in six
+# places, so choosing light mode left the one screen a user sees most often
+# stubbornly dark. Every other surface -- kitty, GTK, the launcher, btop, tmux,
+# neovim -- had been taught to follow the scheme; this was the last one.
+#
+# It is also the worst place to discover a theming bug, because you find out
+# while locked out of the machine and cannot fix it from there. Hence a test.
+#
+# Generated into a fixture, never the live config: this contract must not
+# retheme the lock screen of the desktop it is running on.
+
+set -uo pipefail
+
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+template="$repo_dir/config/dot/hypr/hyprlock.conf.template"
+theme_apps="$repo_dir/config/dot/quickshell/scripts/panama-theme-apps"
+
+fail() {
+ printf 'lock screen theme contract: %s\n' "$1" >&2
+ exit 1
+}
+
+[[ -r "$template" ]] || fail 'hyprlock.conf.template is missing, so nothing generates the lock screen'
+
+# The committed template must carry no literal colours. One left behind is a
+# colour that silently stays dark in light mode -- exactly the original bug.
+literal="$(grep -vE '^\s*#' "$template" | grep -oE 'rgba\([0-9]+, *[0-9]+, *[0-9]+' || true)"
+[[ -z "$literal" ]] \
+ || fail "the template still contains hardcoded colours, which will not follow the scheme: $literal"
+
+fixture="$(mktemp -d /tmp/panama-lockscreen.XXXXXX)"
+trap 'rm -rf "$fixture"' EXIT
+mkdir -p "$fixture/hypr"
+cp "$template" "$fixture/hypr/"
+
+for scheme in dark light; do
+ XDG_CONFIG_HOME="$fixture" "$theme_apps" "$scheme" >/dev/null 2>&1
+ generated="$fixture/hypr/hyprlock.conf"
+
+ [[ -r "$generated" ]] || fail "no hyprlock.conf was generated for $scheme"
+
+ # An unsubstituted placeholder is not a parse error to hyprlock; it is an
+ # invalid colour it quietly ignores, falling back to its own default.
+ leftover="$(grep -oE '@[A-Z_]+@' "$generated" || true)"
+ [[ -z "$leftover" ]] \
+ || fail "$scheme left placeholders unsubstituted: $leftover"
+
+ # Every colour hyprlock is given must be a complete decimal triple. It
+ # takes rgba(r, g, b, a), NOT hex, and a hex value here is silently ignored.
+ while read -r colour; do
+ [[ -n "$colour" ]] || continue
+ grep -qE '^rgba\([0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}$' <<<"$colour" \
+ || fail "$scheme produced a malformed colour: $colour"
+ done < <(grep -vE '^\s*#' "$generated" | grep -oE 'rgba\([^)]*' | sed 's/,[^,]*$//')
+
+ # The Pango markup values are hex, and hyprlock wants them doubled-hashed.
+ while read -r pango; do
+ [[ -n "$pango" ]] || continue
+ grep -qE '^##[0-9a-f]{6}$' <<<"$pango" \
+ || fail "$scheme produced malformed Pango markup colour: $pango"
+ done < <(grep -vE '^\s*#' "$generated" | grep -oE '##[0-9a-fA-F]{6}')
+
+ # The whole background block, not a fixed number of lines after it: the
+ # colour sits near the end, after the blur and noise settings.
+ background="$(awk '/^background \{/,/^\}/' "$generated" \
+ | grep -vE '^\s*#' | grep -oE 'color = rgba\([0-9]+' | grep -oE '[0-9]+$' | head -1)"
+ [[ -n "$background" ]] || fail "$scheme produced no background colour"
+
+ # The point of the whole exercise: a light lock screen must actually be
+ # light. 128 splits the two cleanly for these palettes.
+ if [[ "$scheme" == "light" ]]; then
+ (( background > 128 )) \
+ || fail "light mode produced a DARK lock screen background (red channel $background) -- the original bug"
+ else
+ (( background < 128 )) \
+ || fail "dark mode produced a LIGHT lock screen background (red channel $background)"
+ fi
+done
+
+# The two schemes must actually differ, or the substitution is a no-op that
+# passes every check above.
+XDG_CONFIG_HOME="$fixture" "$theme_apps" dark >/dev/null 2>&1
+dark_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
+XDG_CONFIG_HOME="$fixture" "$theme_apps" light >/dev/null 2>&1
+light_hash="$(sha256sum "$fixture/hypr/hyprlock.conf" | cut -d' ' -f1)"
+[[ "$dark_hash" != "$light_hash" ]] \
+ || fail 'the light and dark lock screens are byte-identical, so the scheme is not being applied'
+
+printf 'lock screen theme contract: PASS\n'