diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml
index 8861a9d..ac0dbd2 100644
--- a/config/dot/quickshell/config/PreferenceSchema.qml
+++ b/config/dot/quickshell/config/PreferenceSchema.qml
@@ -748,6 +748,40 @@ Singleton {
detail: "Shown on every output"
},
+ // ── Lock-screen appearance ─────────────────────────────────────────
+ // scripts/panama-lock validates these again before generating a state
+ // config. The tracked hyprlock.conf remains the safe fallback.
+ {
+ key: "lockBackgroundMode", type: "enum", def: "screenshot", group: "lockAppearance",
+ label: "Background", detail: "What appears behind the lock screen",
+ options: [
+ { value: "screenshot", label: "Blurred desktop" },
+ { value: "wallpaper", label: "Current wallpaper" },
+ { value: "solid", label: "Solid color" }
+ ]
+ },
+ {
+ key: "lockBlurLevel", type: "int", def: 3, min: 0, max: 5, step: 1,
+ group: "lockAppearance", label: "Background blur",
+ detail: "Softens what is behind the password field"
+ },
+ {
+ key: "lockShowClock", type: "bool", def: true, group: "lockAppearance",
+ label: "Show clock", detail: "Use the desktop's 12 or 24-hour format"
+ },
+ {
+ key: "lockShowDate", type: "bool", def: true, group: "lockAppearance",
+ label: "Show date", detail: "Show the weekday and full date"
+ },
+ {
+ key: "lockShowUser", type: "bool", def: true, group: "lockAppearance",
+ label: "Show user name", detail: "Identify the signed-in account"
+ },
+ {
+ key: "lockFadeOnEmpty", type: "bool", def: false, group: "lockAppearance",
+ label: "Hide password field until typing", detail: "Keep the empty field out of the way"
+ },
+
// ── Idle, lock, and sleep ───────────────────────────────────────────
// Written into a generated hypridle config; see scripts/panama-idle.
// Zero means never for all three.
diff --git a/config/dot/quickshell/scripts/panama-lock b/config/dot/quickshell/scripts/panama-lock
new file mode 100755
index 0000000..f38ff66
--- /dev/null
+++ b/config/dot/quickshell/scripts/panama-lock
@@ -0,0 +1,336 @@
+#!/usr/bin/env bash
+
+# Generates Panama's hyprlock configuration into the state directory. The
+# tracked config is never rewritten and remains the fallback if generation
+# fails, so a malformed preference can never leave the session without a
+# working locker.
+
+set -euo pipefail
+
+config_home="${XDG_CONFIG_HOME:-$HOME/.config}"
+state_home="${XDG_STATE_HOME:-$HOME/.local/state}"
+settings="$config_home/panama/settings.json"
+state_dir="$state_home/panama"
+generated="$state_dir/hyprlock.conf"
+status_file="$state_dir/hyprlock-status.json"
+fallback="$config_home/hypr/hyprlock.conf"
+temporary="$generated.tmp.$$"
+status_temporary="$status_file.tmp.$$"
+
+settings_valid=false
+
+cleanup() {
+ rm -f "$temporary" "$status_temporary" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+read_string() {
+ local key="$1" default="$2" value
+ if [[ "$settings_valid" != true ]]; then
+ printf '%s' "$default"
+ return
+ fi
+ value="$(jq -er --arg key "$key" \
+ 'if has($key) and (.[$key] | type) == "string" then .[$key] else empty end' \
+ "$settings" 2>/dev/null)" || value="$default"
+ printf '%s' "$value"
+}
+
+read_bool() {
+ local key="$1" default="$2" value
+ if [[ "$settings_valid" != true ]]; then
+ printf '%s' "$default"
+ return
+ fi
+ value="$(jq -er --arg key "$key" \
+ 'if has($key) and (.[$key] | type) == "boolean" then (.[$key] | tostring) else empty end' \
+ "$settings" 2>/dev/null)" || value="$default"
+ printf '%s' "$value"
+}
+
+read_int() {
+ local key="$1" default="$2" low="$3" high="$4" value
+ if [[ "$settings_valid" != true ]]; then
+ printf '%s' "$default"
+ return
+ fi
+ value="$(jq -er --arg key "$key" \
+ 'if has($key) and (.[$key] | type) == "number" and (.[$key] | floor) == .[$key]
+ then (.[$key] | tostring) else empty end' "$settings" 2>/dev/null)" || value="$default"
+ if [[ ! "$value" =~ ^-?[0-9]+$ ]] || (( value < low || value > high )); then
+ value="$default"
+ fi
+ printf '%s' "$value"
+}
+
+read_object() {
+ local key="$1"
+ if [[ "$settings_valid" != true ]]; then
+ printf '{}'
+ return
+ fi
+ jq -c --arg key "$key" \
+ 'if has($key) and (.[$key] | type) == "object" then .[$key] else {} end' \
+ "$settings" 2>/dev/null || printf '{}'
+}
+
+valid_path() {
+ [[ "$1" == /* && "$1" != *","* && "$1" != *$'\n'* ]]
+}
+
+load_preferences() {
+ if [[ -r "$settings" ]] && jq -e 'type == "object"' "$settings" >/dev/null 2>&1; then
+ settings_valid=true
+ else
+ settings_valid=false
+ fi
+
+ background_mode="$(read_string lockBackgroundMode screenshot)"
+ [[ "$background_mode" == screenshot || "$background_mode" == wallpaper || "$background_mode" == solid ]] \
+ || background_mode=screenshot
+
+ blur_level="$(read_int lockBlurLevel 3 0 5)"
+ show_clock="$(read_bool lockShowClock true)"
+ show_date="$(read_bool lockShowDate true)"
+ show_user="$(read_bool lockShowUser true)"
+ fade_on_empty="$(read_bool lockFadeOnEmpty false)"
+ use_24_hour="$(read_bool use24Hour false)"
+
+ color_scheme="$(read_string colorScheme dark)"
+ [[ "$color_scheme" == dark || "$color_scheme" == light ]] || color_scheme=dark
+
+ wallpaper_mode="$(read_string wallpaperMode single)"
+ [[ "$wallpaper_mode" == single || "$wallpaper_mode" == slideshow || "$wallpaper_mode" == per-monitor ]] \
+ || wallpaper_mode=single
+ wallpaper_path="$(read_string wallpaperPath '')"
+ if ! valid_path "$wallpaper_path"; then
+ wallpaper_path="$HOME/Pictures/Wallpapers/faroe_islands.jpg"
+ fi
+ wallpaper_assignments="$(read_object wallpaperPerMonitor)"
+
+ case "$blur_level" in
+ 0) blur_passes=0; blur_size=1 ;;
+ 1) blur_passes=1; blur_size=3 ;;
+ 2) blur_passes=2; blur_size=5 ;;
+ 3) blur_passes=3; blur_size=8 ;;
+ 4) blur_passes=4; blur_size=10 ;;
+ 5) blur_passes=5; blur_size=12 ;;
+ esac
+
+ if [[ "$color_scheme" == light ]]; then
+ background_color='rgba(245, 246, 250, 1.0)'
+ foreground_color='rgba(55, 63, 87, 1.0)'
+ dim_color='rgba(111, 119, 151, 1.0)'
+ field_color='rgba(220, 223, 232, 0.88)'
+ else
+ background_color='rgba(34, 36, 54, 1.0)'
+ foreground_color='rgba(200, 211, 245, 1.0)'
+ dim_color='rgba(130, 139, 184, 1.0)'
+ field_color='rgba(46, 47, 61, 0.85)'
+ fi
+}
+
+monitor_names() {
+ hyprctl -j monitors 2>/dev/null \
+ | jq -r '.[]? | .name | select(type == "string") | select(test("^[A-Za-z0-9_.-]+$"))' \
+ 2>/dev/null || true
+}
+
+emit_background() {
+ local monitor="$1" path="$2"
+ printf 'background {\n'
+ printf ' monitor = %s\n' "$monitor"
+ if [[ -n "$path" ]]; then
+ printf ' path = %s\n' "$path"
+ fi
+ printf ' blur_passes = %s\n' "$blur_passes"
+ printf ' blur_size = %s\n' "$blur_size"
+ printf ' noise = 0.0117\n'
+ printf ' contrast = 0.9\n'
+ printf ' brightness = 0.8\n'
+ printf ' vibrancy = 0.17\n'
+ printf ' vibrancy_darkness = 0.05\n'
+ printf ' color = %s\n' "$background_color"
+ printf ' zindex = -1\n'
+ printf '}\n\n'
+}
+
+emit_backgrounds() {
+ local output assigned saw_output=false
+ case "$background_mode" in
+ screenshot)
+ emit_background '' screenshot
+ ;;
+ solid)
+ emit_background '' ''
+ ;;
+ wallpaper)
+ while IFS= read -r output; do
+ [[ -n "$output" ]] || continue
+ saw_output=true
+ assigned="$wallpaper_path"
+ if [[ "$wallpaper_mode" == per-monitor ]]; then
+ candidate="$(jq -r --arg output "$output" \
+ 'if has($output) and (.[$output] | type) == "string" then .[$output] else "" end' \
+ <<<"$wallpaper_assignments" 2>/dev/null || true)"
+ if valid_path "$candidate"; then
+ assigned="$candidate"
+ fi
+ fi
+ emit_background "$output" "$assigned"
+ done < <(monitor_names)
+ if [[ "$saw_output" != true ]]; then
+ emit_background '' "$wallpaper_path"
+ fi
+ ;;
+ esac
+}
+
+emit_config() {
+ printf '# Generated by Panama. Do not edit.\n\n'
+ printf 'general {\n'
+ printf ' hide_cursor = true\n'
+ printf ' fractional_scaling = 2\n'
+ printf ' screencopy_mode = 0\n'
+ printf ' fail_timeout = 2000\n'
+ printf '}\n\n'
+ printf 'auth {\n'
+ printf ' pam:enabled = true\n'
+ printf ' pam:module = hyprlock\n'
+ printf '}\n\n'
+
+ emit_backgrounds
+
+ if [[ "$show_clock" == true ]]; then
+ printf 'label {\n'
+ printf ' monitor =\n'
+ if [[ "$use_24_hour" == true ]]; then
+ printf ' text = cmd[update:1000] date +"%%H:%%M"\n'
+ else
+ printf ' text = cmd[update:1000] date +"%%-I:%%M"\n'
+ fi
+ printf ' color = %s\n' "$foreground_color"
+ printf ' font_size = 120\n'
+ printf ' font_family = Adwaita Sans Light\n'
+ printf ' position = 0, 260\n'
+ printf ' halign = center\n'
+ printf ' valign = center\n'
+ printf '}\n\n'
+ fi
+
+ if [[ "$show_date" == true ]]; then
+ printf 'label {\n'
+ printf ' monitor =\n'
+ printf ' text = cmd[update:60000] date +"%%A, %%B %%-d"\n'
+ printf ' color = %s\n' "$dim_color"
+ printf ' font_size = 24\n'
+ printf ' font_family = Adwaita Sans\n'
+ printf ' position = 0, 160\n'
+ printf ' halign = center\n'
+ printf ' valign = center\n'
+ printf '}\n\n'
+ fi
+
+ printf 'input-field {\n'
+ printf ' monitor =\n'
+ printf ' size = 340, 52\n'
+ printf ' position = 0, -40\n'
+ printf ' halign = center\n'
+ printf ' valign = center\n'
+ printf ' outline_thickness = 2\n'
+ printf ' rounding = 26\n'
+ printf ' outer_color = rgba(130, 170, 255, 0.9)\n'
+ printf ' inner_color = %s\n' "$field_color"
+ printf ' font_color = %s\n' "$foreground_color"
+ printf ' check_color = rgba(130, 170, 255, 1.0)\n'
+ printf ' fail_color = rgba(255, 117, 127, 1.0)\n'
+ printf ' dots_size = 0.25\n'
+ printf ' dots_spacing = 0.3\n'
+ printf ' dots_center = true\n'
+ printf ' placeholder_text = Password\n'
+ printf ' fail_text = $FAIL ($ATTEMPTS)\n'
+ printf ' fade_on_empty = %s\n' "$fade_on_empty"
+ printf ' hide_input = false\n'
+ printf '}\n\n'
+
+ if [[ "$show_user" == true ]]; then
+ printf 'label {\n'
+ printf ' monitor =\n'
+ printf ' text = $USER\n'
+ printf ' color = %s\n' "$foreground_color"
+ printf ' font_size = 16\n'
+ printf ' font_family = Adwaita Sans\n'
+ printf ' position = 0, -110\n'
+ printf ' halign = center\n'
+ printf ' valign = center\n'
+ printf '}\n'
+ fi
+}
+
+write_status() {
+ local generated_value="$1" path_value="$2" fallback_value="$3" error_value="$4"
+ mkdir -p "$state_dir" 2>/dev/null || return 0
+ [[ -w "$state_dir" ]] || return 0
+ jq -nc --argjson generated "$generated_value" --arg path "$path_value" \
+ --argjson fallback "$fallback_value" --arg error "$error_value" \
+ '{generated:$generated,path:$path,fallback:$fallback,error:$error}' \
+ >"$status_temporary" 2>/dev/null || return 0
+ mv "$status_temporary" "$status_file" 2>/dev/null || true
+}
+
+generate() {
+ load_preferences
+ mkdir -p "$state_dir" || return 1
+ if ! emit_config >"$temporary"; then
+ rm -f "$temporary" 2>/dev/null || true
+ write_status false "$fallback" true "The lock-screen configuration could not be generated."
+ return 1
+ fi
+ if [[ ! -s "$temporary" ]] \
+ || ! rg -q '^auth \{' "$temporary" \
+ || ! rg -q '^background \{' "$temporary" \
+ || ! rg -q '^input-field \{' "$temporary"; then
+ rm -f "$temporary"
+ write_status false "$fallback" true "The lock-screen configuration could not be generated."
+ return 1
+ fi
+ if ! mv "$temporary" "$generated"; then
+ rm -f "$temporary" 2>/dev/null || true
+ write_status false "$fallback" true "The lock-screen configuration could not be generated."
+ return 1
+ fi
+ write_status true "$generated" false ""
+}
+
+status() {
+ if [[ -r "$status_file" ]] \
+ && jq -e '.generated | type == "boolean"' "$status_file" >/dev/null 2>&1 \
+ && jq -e '.path | type == "string"' "$status_file" >/dev/null 2>&1; then
+ jq -c '{generated,path,fallback:(.fallback == true),error:(.error // "")}' "$status_file"
+ elif [[ -s "$generated" ]]; then
+ jq -nc --arg path "$generated" '{generated:true,path:$path,fallback:false,error:""}'
+ else
+ jq -nc --arg path "$fallback" \
+ '{generated:false,path:$path,fallback:true,error:"The generated lock-screen configuration is unavailable."}'
+ fi
+}
+
+case "${1:-status}" in
+ generate)
+ generate
+ ;;
+ status)
+ status
+ ;;
+ run)
+ if generate >/dev/null 2>&1; then
+ exec hyprlock -c "$generated"
+ fi
+ write_status false "$fallback" true "The lock-screen configuration could not be generated."
+ exec hyprlock -c "$fallback"
+ ;;
+ *)
+ printf 'usage: panama-lock [generate|status|run]\n' >&2
+ exit 2
+ ;;
+esac
diff --git a/tests/quickshell/lock-screen-helper-contract.sh b/tests/quickshell/lock-screen-helper-contract.sh
new file mode 100755
index 0000000..b3ec062
--- /dev/null
+++ b/tests/quickshell/lock-screen-helper-contract.sh
@@ -0,0 +1,152 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+helper="$repo_dir/config/dot/quickshell/scripts/panama-lock"
+schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
+fixture="$(mktemp -d /tmp/panama-lock-helper.XXXXXX)"
+fixture_home="$fixture/home"
+config_home="$fixture/config"
+state_home="$fixture/state"
+test_bin="$fixture/bin"
+settings="$config_home/panama/settings.json"
+generated="$state_home/panama/hyprlock.conf"
+hyprlock_log="$fixture/hyprlock.log"
+
+fail() {
+ printf 'lock screen helper contract: %s\n' "$1" >&2
+ exit 1
+}
+
+cleanup() {
+ chmod 700 "$state_home/panama" 2>/dev/null || true
+ rm -rf "$fixture"
+}
+trap cleanup EXIT
+
+mkdir -p "$fixture_home/Pictures/Wallpapers" "$config_home/panama" \
+ "$config_home/hypr" "$state_home" "$test_bin"
+cp "$repo_dir/config/dot/hypr/hyprlock.conf" "$config_home/hypr/hyprlock.conf"
+
+cat >"$test_bin/hyprctl" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
+ printf '%s\n' '[{"name":"DP-2"},{"name":"HDMI-A-1"}]'
+ exit 0
+fi
+exit 91
+EOF
+chmod +x "$test_bin/hyprctl"
+
+cat >"$test_bin/hyprlock" <<'EOF'
+#!/usr/bin/env bash
+set -euo pipefail
+printf '%s\n' "$*" >>"$PANAMA_TEST_HYPRLOCK_LOG"
+EOF
+chmod +x "$test_bin/hyprlock"
+
+run_helper() {
+ HOME="$fixture_home" XDG_CONFIG_HOME="$config_home" XDG_STATE_HOME="$state_home" \
+ PATH="$test_bin:$PATH" PANAMA_TEST_HYPRLOCK_LOG="$hyprlock_log" "$helper" "$@"
+}
+
+write_settings() {
+ printf '%s\n' "$1" >"$settings"
+}
+
+assert_schema_entry() {
+ local key="$1"
+ rg -Fq "key: \"$key\"" "$schema" || fail "schema is missing $key"
+}
+
+[[ -x "$helper" ]] || fail 'panama-lock helper is missing or not executable'
+for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowUser lockFadeOnEmpty; do
+ assert_schema_entry "$key"
+done
+
+write_settings '{}'
+run_helper generate
+[[ -s "$generated" ]] || fail 'default generation did not create a config'
+rg -Fq 'path = screenshot' "$generated" || fail 'default background is not a screenshot'
+rg -Fq 'blur_passes = 3' "$generated" || fail 'default blur pass count is not 3'
+rg -Fq 'blur_size = 8' "$generated" || fail 'default blur size is not 8'
+rg -Fq 'text = cmd[update:1000] date +"%-I:%M"' "$generated" || fail 'default clock is not 12-hour'
+rg -Fq 'text = cmd[update:60000] date +"%A, %B %-d"' "$generated" || fail 'default date is missing'
+rg -Fq 'text = $USER' "$generated" || fail 'default user label is missing'
+rg -Fq 'fade_on_empty = false' "$generated" || fail 'default password field fades when empty'
+jq -e '.generated == true and .fallback == false and .error == "" and (.path | endswith("/panama/hyprlock.conf"))' \
+ <<<"$(run_helper status)" >/dev/null || fail 'status did not report the generated config'
+
+write_settings '{
+ "lockBackgroundMode":"solid",
+ "lockBlurLevel":0,
+ "lockShowClock":false,
+ "lockShowDate":false,
+ "lockShowUser":false,
+ "lockFadeOnEmpty":true,
+ "use24Hour":true,
+ "colorScheme":"light"
+}'
+run_helper generate
+rg -Fq 'color = rgba(245, 246, 250, 1.0)' "$generated" || fail 'light solid mode has the wrong colour'
+rg -Fq 'blur_passes = 0' "$generated" || fail 'blur level zero did not disable passes'
+rg -Fq 'blur_size = 1' "$generated" || fail 'blur level zero did not use the safe size'
+rg -Fq 'fade_on_empty = true' "$generated" || fail 'fade-on-empty setting was ignored'
+if rg -q '^label \{' "$generated"; then
+ fail 'hidden clock, date, and user labels were still generated'
+fi
+
+write_settings '{
+ "lockBackgroundMode":"wallpaper",
+ "wallpaperMode":"per-monitor",
+ "wallpaperPath":"/images/global.jpg",
+ "wallpaperPerMonitor":{"DP-2":"/images/portrait.jpg"}
+}'
+run_helper generate
+[[ "$(rg -c '^background \{' "$generated")" -eq 2 ]] || fail 'wallpaper mode did not create one block per output'
+awk '/^background \{/{block++} block==1 && /monitor = DP-2/{monitor=1} block==1 && /path = \/images\/portrait.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \
+ || fail 'per-monitor wallpaper was not used for DP-2'
+awk '/^background \{/{block++} block==2 && /monitor = HDMI-A-1/{monitor=1} block==2 && /path = \/images\/global.jpg/{path=1} END{exit !(monitor && path)}' "$generated" \
+ || fail 'missing monitor assignment did not fall back to the global wallpaper'
+
+write_settings '{"lockBackgroundMode":"screenshot","lockShowClock":true,"lockShowDate":false,"lockShowUser":false,"use24Hour":true}'
+run_helper generate
+rg -Fq 'text = cmd[update:1000] date +"%H:%M"' "$generated" || fail '24-hour clock setting was ignored'
+
+write_settings '{"lockBackgroundMode":"nope","lockBlurLevel":99,"lockShowClock":"yes","lockShowDate":0,"lockShowUser":null,"lockFadeOnEmpty":[]}'
+run_helper generate
+rg -Fq 'path = screenshot' "$generated" || fail 'invalid background mode did not use the default'
+rg -Fq 'blur_passes = 3' "$generated" || fail 'invalid blur did not use the default'
+rg -Fq 'text = cmd[update:1000] date +"%-I:%M"' "$generated" || fail 'invalid clock boolean did not use the default'
+rg -Fq 'text = cmd[update:60000] date +"%A, %B %-d"' "$generated" || fail 'invalid date boolean did not use the default'
+rg -Fq 'text = $USER' "$generated" || fail 'invalid user boolean did not use the default'
+rg -Fq 'fade_on_empty = false' "$generated" || fail 'invalid fade boolean did not use the default'
+
+write_settings 'not json'
+run_helper generate
+rg -Fq 'path = screenshot' "$generated" || fail 'malformed JSON did not use shipped defaults'
+
+before_checksum="$(sha256sum "$generated" | cut -d' ' -f1)"
+chmod 500 "$state_home/panama"
+if run_helper generate >/dev/null 2>&1; then
+ fail 'generation unexpectedly succeeded with an unwritable state directory'
+fi
+after_checksum="$(sha256sum "$generated" | cut -d' ' -f1)"
+[[ "$before_checksum" == "$after_checksum" ]] || fail 'failed generation replaced the last valid config'
+chmod 700 "$state_home/panama"
+
+: >"$hyprlock_log"
+run_helper run
+[[ "$(tail -1 "$hyprlock_log")" == "-c $generated" ]] || fail 'run did not use the generated config after success'
+
+chmod 500 "$state_home/panama"
+run_helper run 2>"$fixture/fallback.stderr"
+chmod 700 "$state_home/panama"
+[[ "$(tail -1 "$hyprlock_log")" == "-c $config_home/hypr/hyprlock.conf" ]] \
+ || fail 'run did not use the tracked fallback after generation failure'
+[[ ! -s "$fixture/fallback.stderr" ]] || fail 'safe fallback leaked an internal filesystem error'
+
+printf 'lock screen helper contract: PASS\n'