diff --git a/.chezmoiignore.tmpl b/.chezmoiignore.tmpl
index 103290a..7cdb82a 100644
--- a/.chezmoiignore.tmpl
+++ b/.chezmoiignore.tmpl
@@ -37,6 +37,9 @@
pkglist/
+# system/ contains files staged for /etc — installed manually via system/install-greeter.sh
+system/
+
AGENTS.md
GEMINI.md
CLAUDE.md
diff --git a/.chezmoitemplates/apex-neon-lock.conf b/.chezmoitemplates/apex-neon-lock.conf
new file mode 100644
index 0000000..dd5de84
--- /dev/null
+++ b/.chezmoitemplates/apex-neon-lock.conf
@@ -0,0 +1,135 @@
+{{/*
+ Shared apex-neon look for hyprlock + hyprlogin.
+
+ Hyprlang fragment. Sourced by:
+ - ~/.config/hypr/themes/apex-neon-lock.conf (hyprlock, in $HOME)
+ - /etc/hyprlogin/apex-neon.conf (hyprlogin, installed by system/install-greeter.sh)
+
+ Defines: apex-neon palette, font, input-field, clock + date labels.
+ Greeter-only widgets (session picker, layout, username placeholder) stay in hyprlogin.conf.
+*/ -}}
+# apex-neon palette (mirrors ~/Dev/Themes/apex/dist/hyprland/apex-neon-colors.conf)
+$base = rgb(050505)
+$surface = rgb(141414)
+$overlay = rgb(262626)
+$muted = rgb(404040)
+$text = rgb(ededed)
+$love = rgb(ff0044)
+$gold = rgb(ffb700)
+$pine = rgb(00ff99)
+$foam = rgb(00eaff)
+$iris = rgb(9d00ff)
+
+$font = GeistMono Nerd Font
+
+animations {
+ enabled = true
+ bezier = linear, 1, 1, 0, 0
+ animation = fadeIn, 1, 5, linear
+ animation = fadeOut, 1, 5, linear
+ animation = inputFieldDots, 1, 2, linear
+}
+
+# input — bottom center
+input-field {
+ monitor =
+ size = 360, 64
+ outline_thickness = 2
+ dots_size = 0.22
+ dots_spacing = 0.35
+ dots_center = true
+
+ inner_color = rgba(05, 05, 05, 0.55)
+ outer_color = rgba(00, 234, 255, 0.85) rgba(157, 0, 255, 0.85) 45deg
+ check_color = rgba(0, 255, 153, 0.9) rgba(0, 234, 255, 0.9) 45deg
+ fail_color = rgba(255, 0, 68, 0.9) rgba(255, 136, 153, 0.9) 45deg
+
+ font_color = rgba(237, 237, 237, 0.95)
+ fade_on_empty = false
+ rounding = 12
+
+ font_family = $font
+ placeholder_text = password
+
+ position = 0, 120
+ halign = center
+ valign = bottom
+}
+
+# --- TOP-LEFT: two stacked bubbles ---
+# For halign=left valign=top, position = top-left corner of the widget (relative
+# to screen top-left). Shapes and labels share the same convention.
+
+# ── bubble 1: clock + date ──
+shape {
+ monitor =
+ size = 440, 200
+ color = rgba(5, 5, 5, 0.55)
+ rounding = 20
+ border_size = 2
+ border_color = rgba(0, 234, 255, 0.55)
+ position = 40, -50
+ halign = left
+ valign = top
+ z_index = -1
+}
+
+label {
+ monitor =
+ text = cmd[update:1000] date +"%-H:%M"
+ color = rgba(237, 237, 237, 0.95)
+ font_size = 80
+ font_family = $font
+ position = 100, -70
+ halign = left
+ valign = top
+}
+
+label {
+ monitor =
+ text = cmd[update:60000] date +"%A, %B %-d"
+ color = rgba(0, 234, 255, 0.85)
+ font_size = 20
+ font_family = $font
+ position = 130, -180
+ halign = left
+ valign = top
+}
+
+# ── bubble 2: weather + location ──
+shape {
+ monitor =
+ size = 360, 110
+ color = rgba(5, 5, 5, 0.55)
+ rounding = 18
+ border_size = 2
+ border_color = rgba(255, 183, 0, 0.55)
+ position = 40, -280
+ halign = left
+ valign = top
+ z_index = -1
+}
+
+# weather: condition + temp. wttr.in geo-locates by IP; silent on failure.
+label {
+ monitor =
+ text = cmd[update:1800000] curl -fsS --max-time 3 'wttr.in/?format=%c+%t' 2>/dev/null || echo " "
+ color = rgba(255, 183, 0, 0.95)
+ font_size = 22
+ font_family = $font
+ position = 130, -305
+ halign = left
+ valign = top
+}
+
+# location: trim wttr's "City, District, Country" to just the city.
+label {
+ monitor =
+ text = cmd[update:1800000] curl -fsS --max-time 3 'wttr.in/?format=%l' 2>/dev/null | cut -d',' -f1 | sed 's/^./\U&/' || echo " "
+ color = rgba(157, 0, 255, 0.85)
+ font_size = 14
+ font_family = $font
+ position = 185, -348
+ halign = left
+ valign = top
+}
diff --git a/dot_config/hypr/hyprlock.conf.tmpl b/dot_config/hypr/hyprlock.conf.tmpl
index d989488..d47a05b 100644
--- a/dot_config/hypr/hyprlock.conf.tmpl
+++ b/dot_config/hypr/hyprlock.conf.tmpl
@@ -1,70 +1,45 @@
-{{- $lock_bg := (index .chezmoi.config.data "lockscreen-wallpaper") -}}
-{{- if not $lock_bg -}}
- {{- $lock_bg = "~/.config/wallpaper/lockscreen/current" -}}
+{{- $fallback_bg := (index .chezmoi.config.data "lockscreen-wallpaper") -}}
+{{- if not $fallback_bg -}}
+ {{- $fallback_bg = "~/.config/wallpaper/lockscreen/current" -}}
{{- end -}}
+# Shared look (palette, font, input-field, clock, date) comes from the sourced fragment.
+# Same fragment is installed to /etc/hyprlogin/apex-neon.conf and used by hyprlogin.
+source = ~/.config/hypr/themes/apex-neon-lock.conf
+
general {
hide_cursor = true
ignore_empty_input = true
}
-#auth {
-# just leave the defaults
-#}
-
-##################
-### BACKGROUND ###
-##################
+{{ range .monitors -}}
+{{- $wp := index . "lockscreen-wallpaper" -}}
background {
- monitor =
- path = {{ $lock_bg }}
+ monitor = {{ .name }}
+ path = {{ if $wp }}{{ $wp }}{{ else }}{{ $fallback_bg }}{{ end }}
+}
+{{ end -}}
+
+# --- TOP-RIGHT: notification bubble (hyprlock-only; requires quickshell) ---
+shape {
+ monitor =
+ size = 130, 52
+ color = rgba(5, 5, 5, 0.55)
+ rounding = 16
+ border_size = 2
+ border_color = rgba(255, 0, 68, 0.55)
+ position = -100, -76
+ halign = right
+ valign = top
+ z_index = -1
}
-#############
-### INPUT ###
-#############
-input-field {
- size = 250, 60
- outline_thickness = 2
- dots_size = 0.2 # Scale of input-field height, 0.2 - 0.8
- dots_spacing = 0.35 # Scale of dots' absolute size, 0.0 - 1.0
- dots_center = true
- outer_color = rgba(0, 0, 0, 0)
- inner_color = rgba(0, 0, 0, 0.2)
- font_color = rgba(255, 0, 132, 0.8)
- fade_on_empty = false
- rounding = -1
- check_color = rgb(204, 136, 34)
- placeholder_text =
- hide_input = false
- position = 0, -200
- halign = center
- valign = center
-}
-
-############
-### DATA ###
-############
label {
- monitor =
- text = cmd[update:1000] echo "$(date +"%A, %B %d")"
- color = rgba(242, 243, 244, 0.75)
- font_size = 22
- font_family = GeistMono Nerd Font
- position = 0, 300
- halign = center
- valign = center
-}
-
-############
-### TIME ###
-############
-label {
- monitor =
- text = cmd[update:1000] echo "$(date +"%-I:%M")"
- color = rgba(242, 243, 244, 0.75)
- font_size = 95
- font_family = GeistMono Nerd Font
- position = 0, 200
- halign = center
- valign = center
+ monitor =
+ text = cmd[update:5000] n=$(qs ipc call notifications count 2>/dev/null); { [ -n "$n" ] && [ "$n" != "0" ] && printf ' %s' "$n"; } || true
+ color = rgba(255, 0, 68, 0.95)
+ font_size = 20
+ font_family = GeistMono Nerd Font
+ position = -125, -86
+ halign = right
+ valign = top
}
diff --git a/dot_config/hypr/themes/apex-neon-lock.conf.tmpl b/dot_config/hypr/themes/apex-neon-lock.conf.tmpl
new file mode 100644
index 0000000..a1459c1
--- /dev/null
+++ b/dot_config/hypr/themes/apex-neon-lock.conf.tmpl
@@ -0,0 +1 @@
+{{ template "apex-neon-lock.conf" . }}
diff --git a/dot_config/quickshell/notifications/NotificationDaemon.qml b/dot_config/quickshell/notifications/NotificationDaemon.qml
index 95f8c1b..09e30a6 100644
--- a/dot_config/quickshell/notifications/NotificationDaemon.qml
+++ b/dot_config/quickshell/notifications/NotificationDaemon.qml
@@ -1,4 +1,5 @@
import Quickshell
+import Quickshell.Io
import Quickshell.Services.Notifications
import Quickshell.Wayland
import QtQuick
@@ -12,6 +13,12 @@ Scope {
property alias trackedNotifications: server.trackedNotifications
property bool dnd: false
+ // External access (used by hyprlock notification widget via `qs ipc call notifications count`)
+ IpcHandler {
+ target: "notifications"
+ function count(): int { return server.trackedNotifications.values.length }
+ }
+
// Toast IDs currently showing as popups (capped to avoid overflow)
readonly property int maxToasts: 4
property var toastIds: []
diff --git a/system/etc/greetd/greetd.conf.tmpl b/system/etc/greetd/greetd.conf.tmpl
new file mode 100644
index 0000000..e9f986c
--- /dev/null
+++ b/system/etc/greetd/greetd.conf.tmpl
@@ -0,0 +1,11 @@
+# /etc/greetd/config.toml — installed from chezmoi via system/install-greeter.sh
+[terminal]
+vt = 1
+
+[default_session]
+command = "start-hyprland -- --config /etc/hyprlogin/hyprland-greeter.conf"
+user = "greeter"
+
+[commands]
+reboot = ["systemctl", "reboot"]
+poweroff = ["systemctl", "poweroff"]
diff --git a/system/etc/hyprlogin/apex-neon.conf.tmpl b/system/etc/hyprlogin/apex-neon.conf.tmpl
new file mode 100644
index 0000000..a1459c1
--- /dev/null
+++ b/system/etc/hyprlogin/apex-neon.conf.tmpl
@@ -0,0 +1 @@
+{{ template "apex-neon-lock.conf" . }}
diff --git a/system/etc/hyprlogin/hyprland-greeter.conf.tmpl b/system/etc/hyprlogin/hyprland-greeter.conf.tmpl
new file mode 100644
index 0000000..8a82485
--- /dev/null
+++ b/system/etc/hyprlogin/hyprland-greeter.conf.tmpl
@@ -0,0 +1,34 @@
+# Hyprland compositor config for the greeter session (run as user `greeter`)
+# Installed to /etc/hyprlogin/hyprland-greeter.conf by system/install-greeter.sh.
+
+{{ range .monitors -}}
+monitorv2 {
+ output = {{ .name }}
+ mode = {{ .width }}x{{ .height }}@{{ .refresh_rate }}
+ position = {{ .position }}
+ scale = {{ .scale }}
+{{- if hasKey . "transform" }}
+ transform = {{ .transform }}
+{{- end }}
+{{- if hasKey . "vrr" }}
+ vrr = {{ .vrr }}
+{{- end }}
+}
+{{ end }}
+exec-once = hyprlogin
+
+misc {
+ disable_hyprland_logo = true
+ disable_splash_rendering = true
+}
+
+animations {
+ enabled = false
+}
+
+input {
+ kb_layout = us,de
+ kb_options = grp:alt_shift_toggle
+}
+
+bind = ALT, Q, killactive,
diff --git a/system/etc/hyprlogin/hyprlogin.conf.tmpl b/system/etc/hyprlogin/hyprlogin.conf.tmpl
new file mode 100644
index 0000000..1345f4e
--- /dev/null
+++ b/system/etc/hyprlogin/hyprlogin.conf.tmpl
@@ -0,0 +1,62 @@
+# /etc/hyprlogin/hyprlogin.conf — installed from chezmoi via system/install-greeter.sh
+# Shared look (palette, font, input-field, clock, date) comes from the sourced fragment.
+source = /etc/hyprlogin/apex-neon.conf
+
+general {
+ hide_cursor = false
+ immediate_render = true
+ exit_command = hyprctl dispatch exit
+ fail_timeout = 4000
+ debug_mode = false
+ debug_log_path = /tmp/hyprlogin-debug.log
+}
+
+sessions {
+ default_user = {{ .chezmoi.username }}
+ default_session = hyprland-uwsm.desktop
+}
+
+{{ range .monitors -}}
+{{- $wp := index . "lockscreen-wallpaper" -}}
+{{- if $wp -}}
+{{- $ext := $wp | regexFind "\\.[^.]+$" -}}
+background {
+ monitor = {{ .name }}
+ path = /etc/hyprlogin/wallpaper-{{ .name }}{{ $ext }}
+ blur_passes = 0
+}
+{{ end -}}
+{{- end }}
+
+# greeter-only: username placeholder lives here so hyprlock doesn't carry it
+input-field {
+ monitor =
+ placeholder_text_username = username
+ fail_text = $FAIL
+}
+
+# session indicator (click cycles to next)
+label {
+ monitor =
+ text = $GREETD_SESSION
+ color = rgba(157, 0, 255, 0.85)
+ font_size = 14
+ font_family = $font
+ onclick = hyprlogin:session_next
+ position = 30, 30
+ halign = left
+ valign = bottom
+}
+
+# keyboard layout indicator
+label {
+ monitor =
+ text = $LAYOUT
+ color = rgba(255, 183, 0, 0.8)
+ font_size = 14
+ font_family = $font
+ onclick = hyprctl switchxkblayout all next
+ position = -30, 30
+ halign = right
+ valign = bottom
+}
diff --git a/system/install-greeter.sh b/system/install-greeter.sh
new file mode 100755
index 0000000..de3a558
--- /dev/null
+++ b/system/install-greeter.sh
@@ -0,0 +1,113 @@
+#!/usr/bin/env bash
+# Install hyprlogin + greetd config from chezmoi-tracked sources into /etc.
+# Run manually after `chezmoi apply` when files under system/ change.
+#
+# Usage: ~/.local/share/chezmoi/system/install-greeter.sh [--dry-run]
+
+set -euo pipefail
+
+dry_run=0
+[[ "${1:-}" == "--dry-run" ]] && dry_run=1
+
+source_dir="${CHEZMOI_SOURCE_DIR:-}"
+if [[ -z "$source_dir" ]] && command -v chezmoi >/dev/null 2>&1; then
+ source_dir="$(chezmoi source-path 2>/dev/null || true)"
+fi
+[[ -z "$source_dir" ]] && source_dir="$HOME/.local/share/chezmoi"
+sys_dir="$source_dir/system"
+
+if [[ ! -d "$sys_dir" ]]; then
+ echo "no system/ tree in $source_dir" >&2
+ exit 1
+fi
+
+staging="$(mktemp -d -t greeter-staging-XXXXXX)"
+trap 'rm -rf "$staging"' EXIT
+
+# Files to install: source.tmpl → /etc target (mode)
+files=(
+ "etc/greetd/greetd.conf.tmpl /etc/greetd/greetd.conf 0644"
+ "etc/hyprlogin/apex-neon.conf.tmpl /etc/hyprlogin/apex-neon.conf 0644"
+ "etc/hyprlogin/hyprlogin.conf.tmpl /etc/hyprlogin/hyprlogin.conf 0644"
+ "etc/hyprlogin/hyprland-greeter.conf.tmpl /etc/hyprlogin/hyprland-greeter.conf 0644"
+)
+
+render() {
+ local src="$1" dst="$2"
+ mkdir -p "$(dirname "$dst")"
+ chezmoi execute-template --source "$source_dir" < "$src" > "$dst"
+}
+
+needs_install=0
+declare -a plan
+for entry in "${files[@]}"; do
+ read -r rel target mode <<<"$entry"
+ src="$sys_dir/$rel"
+ rendered="$staging/$rel"
+ rendered="${rendered%.tmpl}"
+ render "$src" "$rendered"
+ if [[ ! -f "$target" ]] || ! diff -q "$rendered" "$target" >/dev/null 2>&1; then
+ plan+=("$rendered → $target ($mode)")
+ needs_install=1
+ fi
+done
+
+# Wallpapers: per-monitor, driven by chezmoi data.monitors[].lockscreen-wallpaper
+# Renders a "name|path" line per monitor that has a wallpaper set.
+wp_tmpl='{{ range .monitors }}{{ $wp := index . "lockscreen-wallpaper" }}{{ if $wp }}{{ .name }}|{{ $wp }}
+{{ end }}{{ end }}'
+declare -a wp_plan
+while IFS='|' read -r mon wp; do
+ [[ -z "$mon" || -z "$wp" ]] && continue
+ wp="${wp/#\~/$HOME}"
+ if [[ ! -f "$wp" ]]; then
+ echo "warning: monitor $mon wallpaper missing: $wp" >&2
+ continue
+ fi
+ ext="${wp##*.}"
+ target="/etc/hyprlogin/wallpaper-${mon}.${ext}"
+ if [[ ! -f "$target" ]] || ! cmp -s "$wp" "$target"; then
+ plan+=("$wp → $target (0644)")
+ wp_plan+=("$wp|$target")
+ needs_install=1
+ fi
+done < <(chezmoi execute-template --source "$source_dir" <<<"$wp_tmpl")
+
+if (( ! needs_install )); then
+ echo "nothing to do — /etc files already match chezmoi source"
+ exit 0
+fi
+
+echo "would install:"
+printf ' %s\n' "${plan[@]}"
+
+if (( dry_run )); then
+ exit 0
+fi
+
+# Back up current greetd config once, the first time, for one-step rollback.
+if [[ -f /etc/greetd/greetd.conf && ! -f /etc/greetd/greetd.conf.nwg-hello-bak ]]; then
+ echo "backing up existing /etc/greetd/greetd.conf → /etc/greetd/greetd.conf.nwg-hello-bak"
+ sudo cp -a /etc/greetd/greetd.conf /etc/greetd/greetd.conf.nwg-hello-bak
+fi
+
+for entry in "${files[@]}"; do
+ read -r rel target mode <<<"$entry"
+ rendered="$staging/${rel%.tmpl}"
+ sudo install -Dm"$mode" "$rendered" "$target"
+done
+
+for pair in "${wp_plan[@]:-}"; do
+ [[ -z "$pair" ]] && continue
+ IFS='|' read -r src target <<<"$pair"
+ sudo install -Dm0644 "$src" "$target"
+done
+
+cat <