diff --git a/.chezmoi.toml.tmpl b/.chezmoi.toml.tmpl
new file mode 100644
index 0000000..80a6a3e
--- /dev/null
+++ b/.chezmoi.toml.tmpl
@@ -0,0 +1,57 @@
+{{- /* Detect hostname and suggest default profile */ -}}
+{{- $hostname := .chezmoi.hostname -}}
+{{- $defaultProfile := "desktop" -}}
+{{- if eq $hostname "box" -}}
+{{- $defaultProfile = "desktop" -}}
+{{- else if eq $hostname "bluefin" -}}
+{{- $defaultProfile = "laptop" -}}
+{{- end -}}
+
+{{- /* Prompt for device profile */ -}}
+{{- $deviceProfile := promptStringOnce . "deviceProfile" (printf "Device profile (desktop/laptop) [%s]" $defaultProfile) -}}
+{{- if eq $deviceProfile "" -}}
+{{- $deviceProfile = $defaultProfile -}}
+{{- end -}}
+
+{{- /* Detect distro */ -}}
+{{- $distro := "unknown" -}}
+{{- if stat "/etc/arch-release" -}}
+{{- $distro = "arch" -}}
+{{- else if lookPath "rpm-ostree" -}}
+{{- $distro = "fedora-atomic" -}}
+{{- else if stat "/etc/fedora-release" -}}
+{{- $distro = "fedora" -}}
+{{- end -}}
+
+{{- /* Prompt for secrets path */ -}}
+{{- $secretsPath := promptStringOnce . "secretsPath" "Path to secrets directory [~/secrets]" -}}
+{{- if eq $secretsPath "" -}}
+{{- $secretsPath = "~/secrets" -}}
+{{- end -}}
+
+[data]
+ deviceProfile = {{ $deviceProfile | quote }}
+ hostname = {{ $hostname | quote }}
+ distro = {{ $distro | quote }}
+ secretsPath = {{ $secretsPath | quote }}
+
+ # Device-specific configuration
+ {{- if eq $deviceProfile "desktop" }}
+ primaryMonitor = "DP-1"
+ secondaryMonitor = "DP-2"
+ primaryResolution = "2560x1440@165"
+ secondaryResolution = "2560x1440@60"
+ hasMultipleMonitors = true
+ hasTouchpad = false
+ hasBattery = false
+ idleTimeout = 300
+ {{- else if eq $deviceProfile "laptop" }}
+ primaryMonitor = "eDP-1"
+ secondaryMonitor = ""
+ primaryResolution = "preferred"
+ secondaryResolution = ""
+ hasMultipleMonitors = false
+ hasTouchpad = true
+ hasBattery = true
+ idleTimeout = 180
+ {{- end }}
diff --git a/.chezmoiignore b/.chezmoiignore
new file mode 100644
index 0000000..57d0682
--- /dev/null
+++ b/.chezmoiignore
@@ -0,0 +1,20 @@
+# Ignore README and install script in home directory
+README.md
+install.sh
+
+# Ignore backup files
+*.pre-ai
+*.bak
+*.orig
+*.backup
+
+# Ignore git and editor files
+.git/
+.gitignore
+*.swp
+*~
+.claude/
+
+# OS-specific ignores
+.DS_Store
+Thumbs.db
diff --git a/README.md b/README.md
index 07e87ba..2fc7d54 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,8 @@
# dotfiles
-init script tba
+Dotfiles managed with [chezmoi](https://chezmoi.io/). Supports CachyOS (desktop) and Bluefin (laptop) with device-specific configs for Hyprland, Waybar, and related tools. Templates handle monitor setup, touchpad/gestures, battery modules, and idle behavior automatically based on hostname detection.
+
+```bash
+# Install
+./install.sh
+```
diff --git a/home/Pictures/wlpp/wallhaven-9dkeqd.png b/home/Pictures/wlpp/wallhaven-9dkeqd.png
new file mode 100644
index 0000000..eda269d
Binary files /dev/null and b/home/Pictures/wlpp/wallhaven-9dkeqd.png differ
diff --git a/home/private_dot_config/hypr/autostart.conf.tmpl b/home/private_dot_config/hypr/autostart.conf.tmpl
new file mode 100644
index 0000000..7a42872
--- /dev/null
+++ b/home/private_dot_config/hypr/autostart.conf.tmpl
@@ -0,0 +1,27 @@
+# Autostart applications
+# Device: {{ .deviceProfile }}
+
+# Polkit agent (GUI password prompts)
+exec-once = /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1
+
+# System tray & bar
+exec-once = nm-applet &
+exec-once = waybar
+
+# Notifications
+exec-once = swaync
+
+# Wallpaper & idle
+exec-once = hyprpaper
+exec-once = hypridle
+
+# Clipboard
+exec-once = copyq --start-server
+
+{{- if eq .deviceProfile "desktop" }}
+# Desktop: Hyprland plugins
+exec-once = hyprpm reload -n
+{{- else }}
+# Laptop: Power management
+exec-once = power-profiles-daemon || true
+{{- end }}
diff --git a/home/private_dot_config/hypr/colors.conf b/home/private_dot_config/hypr/colors.conf
new file mode 100644
index 0000000..0932f1b
--- /dev/null
+++ b/home/private_dot_config/hypr/colors.conf
@@ -0,0 +1,18 @@
+$background = rgb(150E11)
+$foreground = rgb(FEEFF0)
+$color0 = rgb(3D373A)
+$color1 = rgb(220615)
+$color2 = rgb(08432C)
+$color3 = rgb(CE2E9E)
+$color4 = rgb(0881BC)
+$color5 = rgb(C1E177)
+$color6 = rgb(FCD0D3)
+$color7 = rgb(F5DFE1)
+$color8 = rgb(AC9C9D)
+$color9 = rgb(220615)
+$color10 = rgb(08432C)
+$color11 = rgb(CE2E9E)
+$color12 = rgb(0881BC)
+$color13 = rgb(C1E177)
+$color14 = rgb(FCD0D3)
+$color15 = rgb(F5DFE1)
diff --git a/home/private_dot_config/hypr/hypridle.conf.tmpl b/home/private_dot_config/hypr/hypridle.conf.tmpl
new file mode 100644
index 0000000..d2499b5
--- /dev/null
+++ b/home/private_dot_config/hypr/hypridle.conf.tmpl
@@ -0,0 +1,30 @@
+# Hypridle Configuration
+# Device: {{ .deviceProfile }} ({{ .hostname }})
+# Idle timeout: {{ .idleTimeout }} seconds
+
+general {
+ lock_cmd = pidof hyprlock || hyprlock
+ before_sleep_cmd = loginctl lock-session
+ after_sleep_cmd = hyprctl dispatch dpms on
+}
+
+listener {
+ timeout = {{ .idleTimeout }}
+ on-timeout = hyprctl dispatch dpms off
+ on-resume = hyprctl dispatch dpms on
+}
+
+{{- if .hasBattery }}
+# Laptop: dim screen before turning off
+listener {
+ timeout = {{ sub .idleTimeout 60 }}
+ on-timeout = brightnessctl -s set 30%
+ on-resume = brightnessctl -r
+}
+
+# Laptop: suspend after extended idle
+listener {
+ timeout = {{ mul .idleTimeout 2 }}
+ on-timeout = systemctl suspend
+}
+{{- end }}
diff --git a/home/private_dot_config/hypr/hyprland.conf.tmpl b/home/private_dot_config/hypr/hyprland.conf.tmpl
new file mode 100644
index 0000000..c9eb76b
--- /dev/null
+++ b/home/private_dot_config/hypr/hyprland.conf.tmpl
@@ -0,0 +1,293 @@
+# Hyprland Configuration
+# Device: {{ .deviceProfile }} ({{ .hostname }})
+# Managed by chezmoi - edit source at ~/dotfiles
+
+source = ~/.config/hypr/colors.conf
+source = ~/.config/hypr/autostart.conf
+source = ~/.config/hypr/workspaces.conf
+source = ~/.config/hypr/monitors.conf
+
+################
+### MONITORS ###
+################
+
+# Monitor configuration is in monitors.conf (device-specific)
+
+###################
+### MY PROGRAMS ###
+###################
+
+$terminal = ghostty
+$fileManager = thunar
+$menu = wofi --show drun
+$run_menu = wofi --show run
+
+#############################
+### ENVIRONMENT VARIABLES ###
+#############################
+
+env = XCURSOR_SIZE,24
+env = HYPRCURSOR_SIZE,24
+
+#####################
+### LOOK AND FEEL ###
+#####################
+
+general {
+ gaps_in = 2
+ gaps_out = 5
+ border_size = 2
+ col.active_border = rgba(33ccffee) rgba(00ff99ee) 45deg
+ col.inactive_border = rgba(595959aa)
+ resize_on_border = false
+ allow_tearing = false
+ layout = dwindle
+}
+
+decoration {
+ rounding = 10
+ rounding_power = 2
+ active_opacity = 1.0
+ inactive_opacity = 1.0
+
+ shadow {
+ enabled = true
+ range = 4
+ render_power = 3
+ color = rgba(1a1a1aee)
+ }
+
+ blur {
+ enabled = true
+ size = 3
+ passes = 1
+ vibrancy = 0.1696
+ }
+}
+
+animations {
+ enabled = yes
+
+ bezier = easeOutQuint,0.23,1,0.32,1
+ bezier = easeInOutCubic,0.65,0.05,0.36,1
+ bezier = linear,0,0,1,1
+ bezier = almostLinear,0.5,0.5,0.75,1.0
+ bezier = quick,0.15,0,0.1,1
+ bezier = snappy,0.2,0.8,0.2,1
+ bezier = overshoot,0.34,1.56,0.64,1
+ bezier = bounce,0.68,-0.55,0.27,1.55
+ bezier = whip,0.1,0.9,0.2,1
+
+ animation = global, 1, 5, default
+ animation = border, 1, 2.5, snappy
+ animation = windows, 1, 2.8, easeOutQuint
+ animation = windowsIn, 1, 2.2, bounce, popin 95%
+ animation = windowsOut, 1, 0.9, whip, popin 85%
+ animation = fadeIn, 1, 0.9, snappy
+ animation = fadeOut, 1, 0.8, snappy
+ animation = fade, 1, 1.6, quick
+ animation = layers, 1, 2.0, easeOutQuint
+ animation = layersIn, 1, 1.8, bounce, fade
+ animation = layersOut, 1, 0.8, whip, fade
+ animation = fadeLayersIn, 1, 0.9, snappy
+ animation = fadeLayersOut, 1, 0.7, snappy
+ animation = workspaces, 1, 1.2, bounce, fade
+ animation = workspacesIn, 1, 0.9, overshoot, fade
+ animation = workspacesOut, 1, 1.0, whip, fade
+}
+
+# Smart gaps window rules
+windowrulev2 = bordersize 0, floating:0, onworkspace:w[tv1]
+windowrulev2 = rounding 0, floating:0, onworkspace:w[tv1]
+windowrulev2 = bordersize 0, floating:0, onworkspace:f[1]
+windowrulev2 = rounding 0, floating:0, onworkspace:f[1]
+
+binds {
+ hide_special_on_workspace_change = true
+ workspace_back_and_forth = true
+}
+
+ecosystem {
+ enforce_permissions = true
+}
+
+dwindle {
+ pseudotile = true
+ preserve_split = true
+}
+
+master {
+ new_status = master
+}
+
+misc {
+ force_default_wallpaper = -1
+ disable_hyprland_logo = false
+ enable_anr_dialog = false
+}
+
+#############
+### INPUT ###
+#############
+
+input {
+ kb_layout = us, ru
+ kb_variant =
+ kb_model =
+ kb_options = caps:escape
+ kb_rules =
+ numlock_by_default = true
+ follow_mouse = 1
+ sensitivity = 0
+
+{{- if .hasTouchpad }}
+ touchpad {
+ natural_scroll = true
+ tap-to-click = true
+ disable_while_typing = true
+ }
+}
+
+gestures {
+ workspace_swipe = true
+ workspace_swipe_fingers = 3
+}
+{{- else }}
+ touchpad {
+ natural_scroll = false
+ }
+}
+{{- end }}
+
+device {
+ name = epic-mouse-v1
+ sensitivity = -0.5
+}
+
+###################
+### KEYBINDINGS ###
+###################
+
+$mainMod = SUPER
+
+# Keyboard layout toggle
+bind = SUPER, SPACE, exec, bash -c "current=$(hyprctl getoption input:kb_layout -j | jq -r '.str'); if [[ $current == \"us,ru\" ]]; then hyprctl keyword input:kb_layout 'ru,us'; else hyprctl keyword input:kb_layout 'us,ru'; fi"
+
+# Wallpapers
+bind = SUPER, bracketright, exec, ~/Scripts/change_wallpaper.sh next
+bind = SUPER, bracketleft, exec, ~/Scripts/change_wallpaper.sh prev
+
+# Core bindings
+bind = $mainMod, Q, exec, $terminal
+bind = $mainMod, K, killactive,
+bind = $mainMod, M, exit,
+bind = $mainMod, E, exec, ~/.config/hypr/scripts/toggle_expo_on_primary.sh
+bind = $mainMod, V, togglefloating,
+bind = $mainMod, R, exec, $menu
+bind = $mainMod SHIFT, R, exec, hyprctl reload
+bind = $mainMod, P, pseudo,
+bind = $mainMod, J, togglesplit,
+bind = $mainMod, L, exec, pactl set-sink-mute @DEFAULT_SINK@ 1 && hyprlock
+bind = $mainMod, t, togglegroup
+
+# VPN switcher
+bind = , F6, exec, ~/.config/hypr/scripts/vpn-switcher.sh
+
+# Move focus with mainMod + arrow keys
+bind = $mainMod, left, movefocus, l
+bind = $mainMod, right, movefocus, r
+bind = $mainMod, up, movefocus, u
+bind = $mainMod, down, movefocus, d
+
+# Switch workspaces with mainMod + [0-9]
+bind = $mainMod, 1, workspace, 1
+bind = $mainMod, 2, workspace, 2
+bind = $mainMod, 3, workspace, 3
+bind = $mainMod, 4, workspace, 4
+bind = $mainMod, 5, workspace, 5
+bind = $mainMod, 6, workspace, 6
+bind = $mainMod, 7, workspace, 7
+bind = $mainMod, 8, workspace, 8
+bind = $mainMod, 9, workspace, 9
+
+# Special workspaces
+bind = SUPER, F12, exec, ~/.config/hypr/scripts/workspace-pin.sh 1337
+bind = , F12, togglespecialworkspace, org
+bind = SUPER, A, togglespecialworkspace, org
+bind = SUPER SHIFT, F12, movetoworkspace, special:org
+bind = SUPER, X, workspace, name:media
+bind = SUPER SHIFT, X, movetoworkspace, name:media
+bind = SUPER SHIFT, C, movetoworkspace, name:tg
+bind = SUPER, c, workspace, name:tg
+bind = $mainMod, S, togglespecialworkspace, termius
+bind = $mainMod SHIFT, S, movetoworkspacesilent, special:termius
+bind = $mainMod, G, togglespecialworkspace, llm
+bind = $mainMod SHIFT, G, movetoworkspacesilent, special:llm
+
+# Move active window to workspace
+bind = $mainMod SHIFT, 1, movetoworkspacesilent, 1
+bind = $mainMod SHIFT, 2, movetoworkspacesilent, 2
+bind = $mainMod SHIFT, 3, movetoworkspacesilent, 3
+bind = $mainMod SHIFT, 4, movetoworkspacesilent, 4
+bind = $mainMod SHIFT, 5, movetoworkspacesilent, 5
+bind = $mainMod SHIFT, 6, movetoworkspacesilent, 6
+bind = $mainMod SHIFT, 7, movetoworkspacesilent, 7
+bind = $mainMod SHIFT, 8, movetoworkspacesilent, 8
+bind = $mainMod SHIFT, 9, movetoworkspacesilent, 9
+bind = $mainMod SHIFT, 0, movetoworkspacesilent, 10
+
+# Screenshot
+bind = , Print, exec, ~/.config/hypr/scripts/screenshot.sh
+
+# Scroll through existing workspaces
+bind = $mainMod, mouse_down, workspace, e+1
+bind = $mainMod, mouse_up, workspace, e-1
+
+# Move/resize windows with mainMod + LMB/RMB
+bindm = $mainMod, mouse:272, movewindow
+bindm = $mainMod, mouse:273, resizewindow
+
+# Media keys
+bindel = ,XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
+bindel = ,XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
+bindel = ,XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
+bindel = ,XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
+{{- if .hasBattery }}
+bindel = ,XF86MonBrightnessUp, exec, brightnessctl s 10%+
+bindel = ,XF86MonBrightnessDown, exec, brightnessctl s 10%-
+{{- end }}
+bindl = , XF86AudioNext, exec, playerctl next
+bindl = , XF86AudioPause, exec, playerctl play-pause
+bindl = , XF86AudioPlay, exec, playerctl play-pause
+bindl = , XF86AudioPrev, exec, playerctl previous
+
+##############################
+### WINDOWS AND WORKSPACES ###
+##############################
+
+# Window rules for apps
+windowrulev2 = workspace name:tg, class:^(org.telegram.desktop)$
+windowrulev2 = workspace name:tg, class:^(discord)$
+windowrulev2 = suppressevent maximize, class:.*
+windowrulev2 = nofocus,class:^$,title:^$,xwayland:1,floating:1,fullscreen:0,pinned:0
+
+#############
+### PLUGINS ###
+#############
+
+plugin {
+ hyprexpo {
+ columns = 3
+ gap_size = 5
+ bg_col = rgb(111111)
+ workspace_method = first 1
+{{- if .hasTouchpad }}
+ enable_gesture = true
+ gesture_fingers = 3
+ gesture_distance = 300
+ gesture_positive = true
+{{- else }}
+ enable_gesture = false
+{{- end }}
+ }
+}
diff --git a/home/private_dot_config/hypr/hyprlock.conf b/home/private_dot_config/hypr/hyprlock.conf
new file mode 100644
index 0000000..5c4b1e1
--- /dev/null
+++ b/home/private_dot_config/hypr/hyprlock.conf
@@ -0,0 +1,124 @@
+# Hyprlock Configuration
+# Sourcing colors
+source = $HOME/.config/hypr/colors.conf
+$Scripts = $HOME/.config/hypr/scripts
+
+general {
+ grace = 1
+}
+
+background {
+ monitor =
+ path = screenshot
+ blur_size = 5
+ blur_passes = 1
+ noise = 0.0117
+ contrast = 1.3000
+ brightness = 0.8000
+ vibrancy = 0.2100
+ vibrancy_darkness = 0.0
+}
+
+input-field {
+ monitor =
+ size = 250, 50
+ outline_thickness = 3
+ dots_size = 0.33
+ dots_spacing = 0.15
+ dots_center = true
+ outer_color = $color5
+ inner_color = $color0
+ font_color = $color12
+ placeholder_text = Password...
+ hide_input = false
+ position = 0, 200
+ halign = center
+ valign = bottom
+}
+
+# Date
+label {
+ monitor =
+ text = cmd[update:18000000] date +'%A, %-d %B %Y'
+ color = $color12
+ font_size = 34
+ font_family = JetBrains Mono Nerd Font 10
+ position = 0, -150
+ halign = center
+ valign = top
+}
+
+# Week
+label {
+ monitor =
+ text = cmd[update:18000000] date +'Week %U'
+ color = $color5
+ font_size = 24
+ font_family = JetBrains Mono Nerd Font 10
+ position = 0, -250
+ halign = center
+ valign = top
+}
+
+# Time
+label {
+ monitor =
+ text = cmd[update:1000] date +"%H:%M:%S"
+ color = $color15
+ font_size = 94
+ font_family = JetBrains Mono Nerd Font 10
+ position = 0, 0
+ halign = center
+ valign = center
+}
+
+# User
+label {
+ monitor =
+ text = $USER
+ color = $color12
+ font_size = 18
+ font_family = Inter Display Medium
+ position = 0, 100
+ halign = center
+ valign = bottom
+}
+
+# Uptime
+label {
+ monitor =
+ text = cmd[update:60000] uptime -p
+ color = $color12
+ font_size = 24
+ font_family = JetBrains Mono Nerd Font 10
+ position = 0, 0
+ halign = right
+ valign = bottom
+}
+
+# Weather
+label {
+ monitor =
+ text = cmd[update:3600000] [ -f ~/.cache/.weather_cache ] && cat ~/.cache/.weather_cache
+ color = $color12
+ font_size = 24
+ font_family = JetBrains Mono Nerd Font 10
+ position = 50, 0
+ halign = left
+ valign = bottom
+}
+
+# Wallpaper image
+image {
+ monitor =
+ path = $HOME/.config/hypr/wallpaper_effects/.wallpaper_current
+ size = 230
+ rounding = -1
+ border_size = 2
+ border_color = $color11
+ rotate = 0
+ reload_time = -1
+ position = 0, 300
+ halign = center
+ valign = bottom
+}
diff --git a/home/private_dot_config/hypr/hyprpaper.conf.tmpl b/home/private_dot_config/hypr/hyprpaper.conf.tmpl
new file mode 100644
index 0000000..f0a4a74
--- /dev/null
+++ b/home/private_dot_config/hypr/hyprpaper.conf.tmpl
@@ -0,0 +1,13 @@
+# Hyprpaper Configuration
+# Device: {{ .deviceProfile }} ({{ .hostname }})
+
+preload = ~/Pictures/wlpp/wallhaven-9dkeqd.png
+
+{{- if eq .deviceProfile "desktop" }}
+wallpaper = {{ .primaryMonitor }}, ~/Pictures/wlpp/wallhaven-9dkeqd.png
+{{- if .secondaryMonitor }}
+wallpaper = {{ .secondaryMonitor }}, ~/Pictures/wlpp/wallhaven-9dkeqd.png
+{{- end }}
+{{- else }}
+wallpaper = {{ .primaryMonitor }}, ~/Pictures/wlpp/wallhaven-9dkeqd.png
+{{- end }}
diff --git a/home/private_dot_config/hypr/monitors.conf.tmpl b/home/private_dot_config/hypr/monitors.conf.tmpl
new file mode 100644
index 0000000..41f024e
--- /dev/null
+++ b/home/private_dot_config/hypr/monitors.conf.tmpl
@@ -0,0 +1,21 @@
+# Monitor Configuration
+# Device: {{ .deviceProfile }} ({{ .hostname }})
+
+{{- if eq .deviceProfile "desktop" }}
+# Desktop dual-monitor setup
+# Primary: {{ .primaryMonitor }} at {{ .primaryResolution }}
+monitor = {{ .primaryMonitor }},{{ .primaryResolution }},0x0,1
+
+{{- if .secondaryMonitor }}
+# Secondary: {{ .secondaryMonitor }} at {{ .secondaryResolution }}
+# Positioned to the right of primary
+monitor = {{ .secondaryMonitor }},{{ .secondaryResolution }},2560x0,1
+{{- end }}
+
+{{- else }}
+# Laptop single monitor
+monitor = {{ .primaryMonitor }},{{ .primaryResolution }},auto,1
+
+# External displays (when docked)
+monitor = ,preferred,auto,1
+{{- end }}
diff --git a/home/private_dot_config/hypr/scripts/executable_screenshot.sh b/home/private_dot_config/hypr/scripts/executable_screenshot.sh
new file mode 100644
index 0000000..785f779
--- /dev/null
+++ b/home/private_dot_config/hypr/scripts/executable_screenshot.sh
@@ -0,0 +1,32 @@
+#!/usr/bin/env sh
+
+if [ -z "$XDG_PICTURES_DIR" ] ; then
+ XDG_PICTURES_DIR="$HOME/Pictures"
+fi
+
+save_dir="${2:-$XDG_PICTURES_DIR/Screenshots}"
+save_file=$(date +'%Y-%m-%d_%H-%M-%S_screenshot.png')
+
+gtkMode=`gsettings get org.gnome.desktop.interface color-scheme | sed "s/'//g" | awk -F '-' '{print $2}'`
+ncolor="-h string:bgcolor:#191724 -h string:fgcolor:#faf4ed -h string:frcolor:#56526e"
+
+if [ "${gtkMode}" == "light" ] ; then
+ ncolor="-h string:bgcolor:#f4ede8 -h string:fgcolor:#9893a5 -h string:frcolor:#908caa"
+fi
+
+if [ ! -d "$save_dir" ] ; then
+ mkdir -p $save_dir
+fi
+
+case $1 in
+p) grim $save_dir/$save_file ;;
+s) grim -g "$(slurp)" - | satty --filename - --fullscreen --output-filename ~/Pictures/Screenshots/satty-$(date '+%Y%m%d-%H:%M:%S').png;;
+*) echo "...valid options are..."
+ echo "p : print screen to $save_dir"
+ echo "s : snip current screen to $save_dir"
+ exit 1 ;;
+esac
+
+if [ -f "$save_dir/$save_file" ] ; then
+ notify-send $ncolor "theme" -a "saved in $save_dir" -i "$save_dir/$save_file" -r 91190 -t 2200
+fi
diff --git a/home/private_dot_config/hypr/scripts/executable_scroll-audio-sink.sh b/home/private_dot_config/hypr/scripts/executable_scroll-audio-sink.sh
new file mode 100644
index 0000000..0291ff0
--- /dev/null
+++ b/home/private_dot_config/hypr/scripts/executable_scroll-audio-sink.sh
@@ -0,0 +1,61 @@
+#!/bin/bash
+
+# Get a list of sink names
+sinks=($(pactl list sinks short | awk '{print $2}'))
+
+# Get the current default sink name
+current_sink=$(pactl info | grep "Default Sink:" | awk '{print $3}')
+
+# Check if we found the current sink
+if [ -z "$current_sink" ]; then
+ echo "Error: Could not determine the current default sink." >&2
+ # Attempt to set the first sink as default if we can't find the current one
+ if [ ${#sinks[@]} -gt 0 ]; then
+ pactl set-default-sink "${sinks[0]}"
+ echo "Attempting to set default sink to first available: ${sinks[0]}" >&2
+ exit 0
+ else
+ echo "Error: No sinks found." >&2
+ exit 1
+ fi
+fi
+
+# Find the index of the current sink in the list
+current_sink_index=-1
+for i in "${!sinks[@]}"; do
+ if [[ "${sinks[$i]}" == "$current_sink" ]]; then
+ current_sink_index=$i
+ break
+ fi
+done
+
+# Check if the current sink was found in the list
+if [ "$current_sink_index" -eq -1 ]; then
+ echo "Error: Current default sink '$current_sink' not found in the list of sinks." >&2
+ # Reset to the first sink as a fallback
+ if [ ${#sinks[@]} -gt 0 ]; then
+ pactl set-default-sink "${sinks[0]}"
+ echo "Resetting to first available sink: ${sinks[0]}" >&2
+ exit 0
+ else
+ echo "Error: No sinks found to reset to." >&2
+ exit 1
+ fi
+fi
+
+
+# Determine the direction
+direction=$1
+
+# Calculate the index of the next sink
+if [ "$direction" == "up" ]; then
+ next_sink_index=$(( (current_sink_index - 1 + ${#sinks[@]}) % ${#sinks[@]} ))
+else # default to down
+ next_sink_index=$(( (current_sink_index + 1) % ${#sinks[@]} ))
+fi
+
+# Get the name of the next sink
+next_sink="${sinks[$next_sink_index]}"
+
+# Set the new default sink
+pactl set-default-sink "$next_sink"
diff --git a/home/private_dot_config/hypr/scripts/executable_toggle_expo_on_primary.sh b/home/private_dot_config/hypr/scripts/executable_toggle_expo_on_primary.sh
new file mode 100644
index 0000000..4e753c7
--- /dev/null
+++ b/home/private_dot_config/hypr/scripts/executable_toggle_expo_on_primary.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+# Just open expo, no special handling
+hyprctl dispatch hyprexpo:expo toggle
diff --git a/home/private_dot_config/hypr/scripts/executable_vpn-switcher-helper.sh b/home/private_dot_config/hypr/scripts/executable_vpn-switcher-helper.sh
new file mode 100644
index 0000000..5bca822
--- /dev/null
+++ b/home/private_dot_config/hypr/scripts/executable_vpn-switcher-helper.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+# Helper script for vpn-switcher to run privileged commands
+# This gets called via pkexec
+
+ACTION="$1"
+shift
+
+case "$ACTION" in
+ wg-up)
+ wg-quick up "$1"
+ ;;
+ wg-down)
+ wg-quick down "$1"
+ ;;
+ systemctl-start)
+ systemctl start "$1"
+ ;;
+ *)
+ echo "Unknown action: $ACTION" >&2
+ exit 1
+ ;;
+esac
diff --git a/home/private_dot_config/hypr/scripts/executable_vpn-switcher.sh.tmpl b/home/private_dot_config/hypr/scripts/executable_vpn-switcher.sh.tmpl
new file mode 100644
index 0000000..001243e
--- /dev/null
+++ b/home/private_dot_config/hypr/scripts/executable_vpn-switcher.sh.tmpl
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+
+import subprocess
+import os
+
+# Secrets directory - configurable via chezmoi
+SECRETS_DIR = os.path.expanduser("{{ .secretsPath }}")
+VPN_DIR = os.path.join(SECRETS_DIR, "vpn") if os.path.isdir(os.path.join(os.path.expanduser("{{ .secretsPath }}"), "vpn")) else os.path.expanduser("~/cfg/vpn")
+HELPER = os.path.expanduser("~/.config/hypr/scripts/vpn-switcher-helper.sh")
+WOFI_CMD = ["wofi", "--dmenu", "--width", "450", "--height", "350", "--prompt", "VPN Switcher", "--cache-file", "/dev/null"]
+
+def run(cmd, check=False):
+ """Run command and return stdout, or None on failure."""
+ try:
+ result = subprocess.run(cmd, capture_output=True, text=True, check=check)
+ return result.stdout.strip()
+ except subprocess.CalledProcessError:
+ return None
+
+def notify(msg):
+ subprocess.run(["notify-send", "VPN Switcher", msg, "-r", "91191", "-t", "2000"])
+
+def get_active_wg():
+ """Get list of active WireGuard interfaces."""
+ out = run(["wg", "show", "interfaces"])
+ return out.split() if out else []
+
+def is_tailscale_up():
+ """Check if tailscale is running."""
+ result = subprocess.run(["tailscale", "status"], capture_output=True)
+ return result.returncode == 0
+
+def get_ts_exit_node():
+ """Get current tailscale exit node if any."""
+ if not is_tailscale_up():
+ return None
+ out = run(["tailscale", "status", "--json"])
+ if out:
+ import json
+ try:
+ data = json.loads(out)
+ return data.get("ExitNodeStatus", {}).get("ID")
+ except json.JSONDecodeError:
+ pass
+ return None
+
+def get_wg_configs():
+ """Get list of WireGuard config files."""
+ configs = []
+ if os.path.isdir(VPN_DIR):
+ for f in os.listdir(VPN_DIR):
+ if f.endswith(".conf"):
+ configs.append(f[:-5]) # Remove .conf
+ return sorted(configs)
+
+def get_ts_accounts():
+ """Get list of tailscale accounts."""
+ out = run(["tailscale", "switch", "--list"])
+ if not out:
+ return []
+
+ accounts = []
+ for line in out.splitlines()[1:]: # Skip header
+ parts = line.split()
+ if len(parts) >= 3:
+ id_, tailnet, account = parts[0], parts[1], parts[2]
+ is_active = account.endswith("*")
+ if is_active:
+ account = account[:-1]
+ accounts.append({"id": id_, "tailnet": tailnet, "account": account, "active": is_active})
+ return accounts
+
+def get_ts_exit_nodes():
+ """Get list of available tailscale exit nodes."""
+ if not is_tailscale_up():
+ return []
+
+ out = run(["tailscale", "exit-node", "list"])
+ if not out:
+ return []
+
+ nodes = []
+ for line in out.splitlines():
+ line = line.strip()
+ # Skip header, empty lines, and comment lines
+ if not line or line.startswith("IP") or line.startswith("#"):
+ continue
+ parts = line.split()
+ if len(parts) >= 4:
+ ip, hostname = parts[0], parts[1]
+ country = parts[2] if len(parts) > 2 else "-"
+ city = parts[3] if len(parts) > 3 else "-"
+ status = " ".join(parts[4:]) if len(parts) > 4 else ""
+ # Skip offline nodes
+ if "offline" in status.lower():
+ continue
+ # Extract short name (before first dot)
+ short_name = hostname.split(".")[0]
+ nodes.append({"hostname": hostname, "short": short_name, "ip": ip, "country": country, "city": city})
+ return nodes
+
+def build_menu():
+ """Build the menu entries."""
+ lines = []
+ active_wg = get_active_wg()
+ ts_exit = get_ts_exit_node()
+
+ # WireGuard section
+ lines.append("━━━ WireGuard ━━━")
+ for name in get_wg_configs():
+ status = "●" if name in active_wg else "○"
+ lines.append(f" {status} {name}")
+
+ # Tailscale accounts section
+ lines.append("━━━ Tailscale Accounts ━━━")
+ for acc in get_ts_accounts():
+ status = "●" if acc["active"] else "○"
+ if acc["active"]:
+ lines.append(f" {status} {acc['tailnet']} ({acc['account']})")
+ else:
+ lines.append(f" {status} {acc['tailnet']} ({acc['account']}) [{acc['id']}]")
+
+ # Tailscale exit nodes section
+ lines.append("━━━ Tailscale Exit Nodes ━━━")
+ if is_tailscale_up():
+ if ts_exit:
+ lines.append(" ● Exit Node Active - Disconnect")
+ for node in get_ts_exit_nodes():
+ lines.append(f" ○ {node['hostname']} ({node['country']}/{node['city']})")
+ else:
+ lines.append(" ○ Start Tailscale")
+
+ # Actions
+ lines.append("━━━ Actions ━━━")
+ lines.append(" ⊘ Disconnect All")
+
+ return "\n".join(lines)
+
+def pkexec_helper(action, arg):
+ """Run helper script via pkexec."""
+ subprocess.run(["pkexec", HELPER, action, arg], capture_output=True)
+
+def disconnect_all_wg():
+ """Disconnect all WireGuard interfaces."""
+ for iface in get_active_wg():
+ conf_path = os.path.join(VPN_DIR, f"{iface}.conf")
+ if os.path.exists(conf_path):
+ pkexec_helper("wg-down", conf_path)
+ else:
+ pkexec_helper("wg-down", iface)
+
+def handle_selection(selection):
+ """Handle the user's menu selection."""
+ # Skip headers and empty
+ if not selection or selection.startswith("━━━"):
+ return
+
+ # Strip leading whitespace and status icon
+ clean = selection.strip()
+ if clean.startswith("●") or clean.startswith("○") or clean.startswith("⊘"):
+ clean = clean[1:].strip()
+
+ # Get current state
+ active_wg = get_active_wg()
+ wg_configs = get_wg_configs()
+ ts_accounts = get_ts_accounts()
+ ts_exit_nodes = get_ts_exit_nodes()
+
+ # Check if it's a WireGuard config
+ for name in wg_configs:
+ if clean == name:
+ if name in active_wg:
+ # Turn off
+ conf_path = os.path.join(VPN_DIR, f"{name}.conf")
+ pkexec_helper("wg-down", conf_path)
+ notify(f"WireGuard: {name} disconnected")
+ else:
+ # Turn on (disable others first)
+ disconnect_all_wg()
+ if is_tailscale_up():
+ run(["tailscale", "set", "--exit-node="])
+ conf_path = os.path.join(VPN_DIR, f"{name}.conf")
+ pkexec_helper("wg-up", conf_path)
+ notify(f"WireGuard: {name} connected")
+ return
+
+ # Check if it's a Tailscale account
+ for acc in ts_accounts:
+ if acc["tailnet"] in clean and acc["account"] in clean:
+ if acc["active"]:
+ notify("Already on this Tailscale account")
+ else:
+ run(["tailscale", "switch", acc["id"]])
+ notify(f"Tailscale: Switched to {acc['tailnet']}")
+ return
+
+ # Check if it's a Tailscale exit node
+ if clean == "Exit Node Active - Disconnect":
+ run(["tailscale", "set", "--exit-node="])
+ notify("Tailscale exit node disconnected")
+ return
+
+ if clean == "Start Tailscale":
+ run(["tailscale", "up"])
+ notify("Tailscale started")
+ return
+
+ for node in ts_exit_nodes:
+ if node["hostname"] in clean or node["short"] in clean:
+ disconnect_all_wg()
+ run(["tailscale", "set", f"--exit-node={node['ip']}"])
+ notify(f"Tailscale: Connected via {node['short']}")
+ return
+
+ # Actions
+ if clean == "Disconnect All":
+ disconnect_all_wg()
+ if is_tailscale_up():
+ run(["tailscale", "set", "--exit-node="])
+ notify("All VPNs disconnected")
+
+def main():
+ menu = build_menu()
+
+ # Run wofi
+ result = subprocess.run(
+ WOFI_CMD,
+ input=menu,
+ capture_output=True,
+ text=True
+ )
+
+ selection = result.stdout.strip()
+ if selection:
+ handle_selection(selection)
+
+if __name__ == "__main__":
+ main()
diff --git a/home/private_dot_config/hypr/scripts/executable_workspace-pin.sh b/home/private_dot_config/hypr/scripts/executable_workspace-pin.sh
new file mode 100644
index 0000000..1d8d3cb
--- /dev/null
+++ b/home/private_dot_config/hypr/scripts/executable_workspace-pin.sh
@@ -0,0 +1,76 @@
+#!/bin/bash
+
+# Configuration
+WORKSPACE=$1
+CORRECT_PIN="1234"
+LOG_FILE="$HOME/.workspace_access_log"
+
+# Fuzzel colors - Nord theme
+BG_COLOR="242a36ee"
+TEXT_COLOR="eceff4ff"
+SELECTION_COLOR="88c0d0ff"
+SELECTION_TEXT_COLOR="2e3440ff"
+BORDER_COLOR="5e81acff"
+
+# Display an informational notification before showing PIN dialog
+notify-send --icon=dialog-password-symbolic \
+ --app-name="Workspace Security" \
+ "Workspace $WORKSPACE" "Protected workspace - PIN required" \
+ --urgency=low
+
+# Get PIN using fuzzel
+ENTERED_PIN=$(echo -e "\n" | fuzzel \
+ --dmenu \
+ --password \
+ --prompt="Enter PIN for workspace $WORKSPACE: " \
+ --width=35 \
+ --lines=0 \
+ --horizontal-pad=20 \
+ --vertical-pad=15 \
+ --inner-pad=10 \
+ --border-width=2 \
+ --border-radius=10 \
+ --font="JetBrains Mono:size=14" \
+ --background=${BG_COLOR} \
+ --text-color=${TEXT_COLOR} \
+ --match-color=${SELECTION_COLOR} \
+ --selection-color=${SELECTION_COLOR} \
+ --selection-text-color=${SELECTION_TEXT_COLOR} \
+ --border-color=${BORDER_COLOR} )
+
+# Trim whitespace
+ENTERED_PIN=$(echo "$ENTERED_PIN" | xargs)
+
+# Check if PIN entry was cancelled (empty)
+if [ -z "$ENTERED_PIN" ]; then
+ notify-send --icon=dialog-information \
+ --app-name="Workspace Security" \
+ "Access Cancelled" "PIN entry was cancelled" \
+ --urgency=low
+ exit 0
+fi
+
+# Verify PIN
+if [ "$ENTERED_PIN" == "$CORRECT_PIN" ]; then
+ # Correct PIN - switch to workspace with nice visual feedback
+ notify-send --icon=dialog-password \
+ --app-name="Workspace Security" \
+ "Access Granted" "Switching to workspace $WORKSPACE" \
+ --urgency=low
+
+ # Optional: Log successful attempt
+ echo "$(date): Successful PIN entry for workspace $WORKSPACE" >> "$LOG_FILE"
+
+ # Small delay for notification visibility before switching
+ sleep 0.2
+ hyprctl dispatch workspace "$WORKSPACE"
+else
+ # Wrong PIN - show notification with error icon
+ notify-send --icon=dialog-error \
+ --app-name="Workspace Security" \
+ "Access Denied" "Incorrect PIN for workspace $WORKSPACE" \
+ --urgency=normal
+
+ # Log failed attempt
+ echo "$(date): Failed PIN attempt for workspace $WORKSPACE" >> "$LOG_FILE"
+fi
diff --git a/home/private_dot_config/hypr/workspaces.conf b/home/private_dot_config/hypr/workspaces.conf
new file mode 100644
index 0000000..1e9ac12
--- /dev/null
+++ b/home/private_dot_config/hypr/workspaces.conf
@@ -0,0 +1,15 @@
+# Workspace definitions
+workspace = 1, name:WEB
+workspace = 2, name:CODE
+workspace = 3, name:TERM
+workspace = 4, name:IDE
+workspace = 5, name:VM
+workspace = 6, name:GFX
+workspace = 7, name:DOC
+workspace = 8, name:GAME
+workspace = 9, name:MISC
+workspace = 10, name:TMP
+
+# Smart gaps rules
+workspace = w[tv1], gapsout:0, gapsin:0
+workspace = f[1], gapsout:0, gapsin:0
diff --git a/home/private_dot_config/waybar/config.jsonc.tmpl b/home/private_dot_config/waybar/config.jsonc.tmpl
new file mode 100644
index 0000000..bdbe820
--- /dev/null
+++ b/home/private_dot_config/waybar/config.jsonc.tmpl
@@ -0,0 +1,124 @@
+// -*- mode: jsonc -*-
+// Device: {{ .deviceProfile }} ({{ .hostname }})
+{
+ "height": 30,
+ "spacing": 6,
+{{- if eq .deviceProfile "desktop" }}
+ "output": ["{{ .primaryMonitor }}"],
+{{- end }}
+ "position": "bottom",
+ "modules-left": ["hyprland/workspaces"],
+ "modules-center": [],
+ "modules-right": [
+ "custom/vpn",
+ "pulseaudio#spk",
+ "pulseaudio#mic",
+ "network",
+{{- if .hasBattery }}
+ "battery",
+ "backlight",
+{{- end }}
+ "cpu",
+ "memory",
+ "clock",
+ "tray"
+ ],
+
+ "hyprland/workspaces": {
+ "disable-scroll": true,
+ "warp-on-scroll": false,
+ "show-special": true,
+ "sort-by": "id",
+ "format": "{name}",
+ "on-scroll-up": "hyprctl dispatch workspace e+1",
+ "on-scroll-down": "hyprctl dispatch workspace e-1",
+ "ignore-workspaces": ["(special:)?chrome-sharing-indicator"],
+ "format-icons": {
+ "1": "WEB",
+ "2": "CODE",
+ "3": "TERM",
+ "4": "IDE",
+ "5": "VM",
+ "6": "GFX",
+ "7": "DOC",
+ "8": "GAME",
+ "9": "MISC",
+ "10": "TMP",
+ "special:termius": "s:term",
+ "special:org": "s:org",
+ "special:llm": "s:llm",
+ "default": "WS"
+ }
+ },
+
+ "hyprland/window": {
+ "format": "{title}",
+ "max-length": 60
+ },
+
+ "pulseaudio#spk": {
+ "scroll-step": 2,
+ "format": "VOL {volume}%",
+ "format-muted": "VOL muted",
+ "on-click": "pavucontrol -t 3",
+ "on-click-right": "pactl set-sink-mute @DEFAULT_SINK@ toggle",
+ "on-scroll-up": "~/.config/hypr/scripts/scroll-audio-sink.sh up",
+ "on-scroll-down": "~/.config/hypr/scripts/scroll-audio-sink.sh down"
+ },
+
+ "pulseaudio#mic": {
+ "scroll-step": 2,
+ "format": "{format_source}",
+ "format-source": "MIC {volume}%",
+ "format-source-muted": "MIC muted",
+ "on-click": "pavucontrol -t 4",
+ "on-click-right": "pactl set-source-mute @DEFAULT_SOURCE@ toggle",
+ "on-scroll-up": "pactl set-source-volume @DEFAULT_SOURCE@ +2%",
+ "on-scroll-down": "pactl set-source-volume @DEFAULT_SOURCE@ -2%"
+ },
+
+ "network": {
+ "format-wifi": "NET wifi {essid} {signalStrength}%",
+ "format-ethernet": "NET eth {ipaddr}",
+ "format-linked": "NET link",
+ "format-disconnected": "NET down",
+ "tooltip-format": "{ifname} via {gwaddr}"
+ },
+
+{{- if .hasBattery }}
+ "battery": {
+ "format": "BAT {capacity}%",
+ "format-charging": "BAT+ {capacity}%",
+ "format-plugged": "BAT= {capacity}%",
+ "format-full": "BAT full",
+ "states": {
+ "warning": 30,
+ "critical": 15
+ }
+ },
+
+ "backlight": {
+ "format": "BRT {percent}%",
+ "on-scroll-up": "brightnessctl s 5%+",
+ "on-scroll-down": "brightnessctl s 5%-"
+ },
+{{- end }}
+
+ "cpu": { "format": "CPU {usage}%", "interval": 5, "tooltip": false },
+ "memory": { "format": "MEM {}%", "interval": 5 },
+
+ "clock": {
+ "format": "TIME {:%Y-%m-%d %H:%M}",
+ "tooltip-format": "{calendar}",
+ "interval": 60
+ },
+
+ "tray": { "spacing": 4 },
+
+ "custom/vpn": {
+ "exec": "~/.config/waybar/scripts/vpn-status.sh",
+ "return-type": "json",
+ "interval": 5,
+ "on-click": "~/.config/hypr/scripts/vpn-switcher.sh"
+ }
+}
diff --git a/home/private_dot_config/waybar/scripts/executable_vpn-status.sh b/home/private_dot_config/waybar/scripts/executable_vpn-status.sh
new file mode 100644
index 0000000..6ba5098
--- /dev/null
+++ b/home/private_dot_config/waybar/scripts/executable_vpn-status.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+# VPN status for waybar - outputs JSON
+
+get_status() {
+ local wg_status=""
+ local nb_status=""
+ local ts_status=""
+ local text="VPN off"
+ local tooltip="No VPN active"
+ local class="disconnected"
+ local has_tunnel=false # true if WG, NetBird, or TS with exit node
+
+ # Check NetBird
+ if command -v netbird &>/dev/null; then
+ nb_json=$(netbird status --json 2>/dev/null)
+ if [[ -n "$nb_json" ]]; then
+ nb_mgmt=$(echo "$nb_json" | jq -r '.management.connected // false')
+ if [[ "$nb_mgmt" == "true" ]]; then
+ nb_ip=$(echo "$nb_json" | jq -r '.netbirdIp // empty' | cut -d'/' -f1)
+ nb_fqdn=$(echo "$nb_json" | jq -r '.fqdn // empty')
+ nb_connected=$(echo "$nb_json" | jq -r '.peers.connected // 0')
+ nb_total=$(echo "$nb_json" | jq -r '.peers.total // 0')
+
+ # Extract network name from fqdn (e.g., box-128-45.lumia.overlay -> lumia)
+ nb_net=$(echo "$nb_fqdn" | awk -F'.' '{print $(NF-1)}')
+
+ nb_status="NB:${nb_net}(${nb_connected}/${nb_total})"
+ tooltip="NetBird: ${nb_fqdn}\nIP: ${nb_ip}\nPeers: ${nb_connected}/${nb_total}"
+ # NetBird mesh connection alone doesn't route all traffic like a VPN
+ fi
+ fi
+ fi
+
+ # Check WireGuard interfaces (excluding NetBird's wt0)
+ wg_ifaces=$(ip link show type wireguard 2>/dev/null | grep -oP '^\d+: \K[^:]+' | grep -v '^wt')
+ if [[ -n "$wg_ifaces" ]]; then
+ wg_name=$(echo "$wg_ifaces" | head -1)
+ wg_status="WG:$wg_name"
+ [[ -z "$tooltip" || "$tooltip" == "No VPN active" ]] && tooltip=""
+ [[ -n "$tooltip" ]] && tooltip+="\n"
+ tooltip+="WireGuard: $wg_name"
+ has_tunnel=true
+ fi
+
+ # Check Tailscale
+ if command -v tailscale &>/dev/null; then
+ ts_json=$(tailscale status --json 2>/dev/null)
+ if [[ -n "$ts_json" ]]; then
+ ts_state=$(echo "$ts_json" | jq -r '.BackendState // empty')
+
+ if [[ "$ts_state" == "Running" ]]; then
+ # Get tailnet name (domain)
+ tailnet=$(echo "$ts_json" | jq -r '.CurrentTailnet.Name // empty')
+ tailnet_short="${tailnet%.ts.net}"
+ tailnet_short="${tailnet_short%.tail*}"
+
+ # Check for exit node
+ exit_node_id=$(echo "$ts_json" | jq -r '.ExitNodeStatus.ID // empty')
+ exit_online=$(echo "$ts_json" | jq -r '.ExitNodeStatus.Online // false')
+
+ if [[ -n "$exit_node_id" && "$exit_online" == "true" ]]; then
+ exit_host=$(echo "$ts_json" | jq -r '
+ .Peer | to_entries[] | select(.value.ExitNode == true) | .value.HostName // "exit"
+ ' 2>/dev/null)
+ [[ -z "$exit_host" || "$exit_host" == "null" ]] && exit_host="exit"
+
+ ts_status="TS:${tailnet_short}→${exit_host}"
+ [[ -n "$tooltip" ]] && tooltip+="\n"
+ tooltip+="Tailscale: ${tailnet}\nExit: ${exit_host}"
+ has_tunnel=true
+ else
+ ts_status="TS:${tailnet_short}"
+ [[ -n "$tooltip" ]] && tooltip+="\n"
+ tooltip+="Tailscale: ${tailnet} (no exit)"
+ # No exit node = no tunnel, don't set has_tunnel
+ fi
+ fi
+ fi
+ fi
+
+ # Build final output
+ local parts=()
+ [[ -n "$wg_status" ]] && parts+=("$wg_status")
+ [[ -n "$nb_status" ]] && parts+=("$nb_status")
+ [[ -n "$ts_status" ]] && parts+=("$ts_status")
+
+ if [[ ${#parts[@]} -gt 0 ]]; then
+ text=$(IFS=' | '; echo "${parts[*]}")
+ fi
+
+ # Only green if actual tunnel (WG, NetBird, or TS+exit)
+ if [[ "$has_tunnel" == true ]]; then
+ class="connected"
+ elif [[ ${#parts[@]} -gt 0 ]]; then
+ class="connected-no-tunnel"
+ fi
+
+ printf '{"text": "%s", "tooltip": "%s", "class": "%s"}\n' "$text" "$tooltip" "$class"
+}
+
+get_status
diff --git a/home/private_dot_config/waybar/style.css b/home/private_dot_config/waybar/style.css
new file mode 100644
index 0000000..689f8b6
--- /dev/null
+++ b/home/private_dot_config/waybar/style.css
@@ -0,0 +1,88 @@
+/* ANSI/BBS monospace */
+* {
+ font-family: "Terminus", "IBM Plex Mono", "JetBrainsMono Nerd Font",
+ monospace;
+ font-size: 12px;
+ min-height: 0;
+}
+
+window#waybar {
+ background: #141414;
+ color: #cccccc;
+ border: 0px solid #cccccc;
+ border-radius: 0;
+ margin: 4px 6px;
+ padding: 2px 6px;
+}
+
+/* Boxy ASCII-ish modules */
+#workspaces button,
+#custom-vpn,
+#pulseaudio,
+#network,
+#battery,
+#backlight,
+#cpu,
+#memory,
+#clock,
+#tray {
+ background: transparent;
+ color: #cccccc;
+ border: 1px dashed #cccccc;
+ border-radius: 0;
+ padding: 1px 6px;
+ margin: 0 3px;
+}
+
+/* Workspaces: focused/urgent with inverse/red */
+#workspaces button:hover {
+ background: #001900;
+}
+#workspaces button.focused,
+#workspaces button.active {
+ background: #cccccc;
+ color: #000;
+ border-color: #cccccc;
+}
+#workspaces button.urgent {
+ background: #ff0000;
+ color: #000;
+ border-color: #ff0000;
+}
+
+/* Specials: dotted border to mark them */
+#workspaces button.special {
+ border-style: dotted;
+}
+
+/* VPN status colors */
+#custom-vpn.connected {
+ color: #00ff00;
+ border-color: #00ff00;
+}
+#custom-vpn.connected-no-tunnel {
+ color: #cccccc;
+}
+#custom-vpn.disconnected {
+ color: #666666;
+}
+
+/* Battery states */
+#battery.warning {
+ color: #ffaa00;
+ border-color: #ffaa00;
+}
+#battery.critical {
+ color: #ff0000;
+ border-color: #ff0000;
+}
+#battery.charging {
+ color: #00ff00;
+}
+
+/* Retro tooltip */
+tooltip {
+ background: #000;
+ color: #cccccc;
+ border: 1px solid #cccccc;
+}
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..7805501
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,180 @@
+#!/usr/bin/env bash
+#
+# Dotfiles Bootstrap & Management Script
+#
+
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m'
+
+log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
+log_ok() { echo -e "${GREEN}[OK]${NC} $*"; }
+log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
+log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
+
+DISTRO="unknown"
+DOTFILES_DIR="$HOME/dotfiles"
+
+detect_distro() {
+ if [[ -f /etc/arch-release ]]; then
+ echo "arch"
+ elif command -v rpm-ostree &>/dev/null; then
+ echo "fedora-atomic"
+ elif [[ -f /etc/fedora-release ]]; then
+ echo "fedora"
+ else
+ echo "unknown"
+ fi
+}
+
+install_chezmoi() {
+ if command -v chezmoi &>/dev/null; then
+ log_ok "chezmoi already installed: $(chezmoi --version | head -1)"
+ return 0
+ fi
+
+ log_info "Installing chezmoi..."
+ case "$DISTRO" in
+ arch)
+ sudo pacman -S --noconfirm chezmoi
+ ;;
+ fedora)
+ sudo dnf install -y chezmoi
+ ;;
+ fedora-atomic)
+ if command -v brew &>/dev/null; then
+ brew install chezmoi
+ else
+ mkdir -p ~/.local/bin
+ sh -c "$(curl -fsLS get.chezmoi.io)" -- -b ~/.local/bin
+ export PATH="$HOME/.local/bin:$PATH"
+ fi
+ ;;
+ *)
+ mkdir -p ~/.local/bin
+ sh -c "$(curl -fsLS get.chezmoi.io)" -- -b ~/.local/bin
+ export PATH="$HOME/.local/bin:$PATH"
+ ;;
+ esac
+ log_ok "chezmoi installed"
+}
+
+setup_secrets() {
+ local secrets_dir="$HOME/secrets"
+ if [[ ! -d "$secrets_dir" ]]; then
+ log_info "Creating secrets directory..."
+ mkdir -p "$secrets_dir/vpn"
+ chmod 700 "$secrets_dir"
+ log_ok "Created $secrets_dir"
+ fi
+}
+
+init_chezmoi() {
+ local repo_url="${1:-}"
+ if [[ -n "$repo_url" ]]; then
+ log_info "Initializing from: $repo_url"
+ chezmoi init "$repo_url"
+ elif [[ -d "$DOTFILES_DIR/home" ]]; then
+ log_info "Initializing from local: $DOTFILES_DIR/home"
+ chezmoi init --source="$DOTFILES_DIR/home" --prompt=false
+ else
+ log_error "No repo URL and ~/dotfiles not found"
+ exit 1
+ fi
+}
+
+is_initialized() {
+ [[ -f "$HOME/.config/chezmoi/chezmoi.toml" ]] && chezmoi source-path &>/dev/null
+}
+
+check_config_outdated() {
+ # Check if config template has changed
+ if chezmoi diff 2>&1 | grep -q "config file template has changed"; then
+ log_warn "Config template changed, regenerating..."
+ chezmoi init --source="$DOTFILES_DIR/home" --prompt=false
+ log_ok "Config regenerated"
+ fi
+}
+
+show_menu() {
+ echo ""
+ echo " 1) diff - Show pending changes"
+ echo " 2) apply - Apply dotfiles"
+ echo " 3) reinit - Re-initialize (change profile)"
+ echo " 4) update - Pull latest and apply"
+ echo " 5) edit - Edit a managed file"
+ echo " q) quit"
+ echo ""
+}
+
+manage_menu() {
+ while true; do
+ show_menu
+ read -rp "Choice: " choice
+ echo ""
+ case "$choice" in
+ 1|diff)
+ chezmoi diff | less -R
+ ;;
+ 2|apply)
+ chezmoi apply -v
+ log_ok "Applied!"
+ ;;
+ 3|reinit)
+ chezmoi init --force
+ ;;
+ 4|update)
+ chezmoi update -v
+ log_ok "Updated!"
+ ;;
+ 5|edit)
+ read -rp "File to edit (e.g. ~/.config/hypr/hyprland.conf): " file
+ [[ -n "$file" ]] && chezmoi edit "$file"
+ ;;
+ q|quit|exit)
+ break
+ ;;
+ *)
+ log_warn "Invalid choice"
+ ;;
+ esac
+ done
+}
+
+main() {
+ DISTRO=$(detect_distro)
+
+ echo ""
+ echo "========================================"
+ echo " Dotfiles Manager "
+ echo "========================================"
+ echo ""
+ log_info "Distro: $DISTRO | Host: $(hostname)"
+
+ install_chezmoi
+ setup_secrets
+
+ if is_initialized; then
+ log_ok "Chezmoi already initialized"
+ check_config_outdated
+ manage_menu
+ else
+ init_chezmoi "${1:-}"
+ echo ""
+ log_ok "Bootstrap complete!"
+ echo ""
+ read -rp "Apply dotfiles now? [Y/n] " apply
+ if [[ ! "$apply" =~ ^[Nn]$ ]]; then
+ chezmoi apply -v
+ log_ok "Applied!"
+ else
+ echo "Run 'chezmoi diff' to review, 'chezmoi apply' to install"
+ fi
+ fi
+}
+
+main "$@"
diff --git a/desktop/firefox/userChrome.css b/legacy/desktop/firefox/userChrome.css
similarity index 100%
rename from desktop/firefox/userChrome.css
rename to legacy/desktop/firefox/userChrome.css
diff --git a/desktop/i3/config b/legacy/desktop/i3/config
similarity index 100%
rename from desktop/i3/config
rename to legacy/desktop/i3/config
diff --git a/desktop/i3blocks.conf b/legacy/desktop/i3blocks.conf
similarity index 100%
rename from desktop/i3blocks.conf
rename to legacy/desktop/i3blocks.conf
diff --git a/desktop/i3lock.service b/legacy/desktop/i3lock.service
similarity index 100%
rename from desktop/i3lock.service
rename to legacy/desktop/i3lock.service
diff --git a/desktop/i3status/config b/legacy/desktop/i3status/config
similarity index 100%
rename from desktop/i3status/config
rename to legacy/desktop/i3status/config
diff --git a/keyboard/Model M/as400.sc b/legacy/keyboard/Model M/as400.sc
similarity index 100%
rename from keyboard/Model M/as400.sc
rename to legacy/keyboard/Model M/as400.sc
diff --git a/keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json b/legacy/keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json
similarity index 100%
rename from keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json
rename to legacy/keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json
diff --git a/macos/alacritty.yml b/legacy/macos/alacritty.yml
similarity index 100%
rename from macos/alacritty.yml
rename to legacy/macos/alacritty.yml
diff --git a/scripts/archive/corona.sh b/legacy/scripts/archive/corona.sh
similarity index 100%
rename from scripts/archive/corona.sh
rename to legacy/scripts/archive/corona.sh
diff --git a/scripts/archive/erc20balance.sh b/legacy/scripts/archive/erc20balance.sh
similarity index 100%
rename from scripts/archive/erc20balance.sh
rename to legacy/scripts/archive/erc20balance.sh
diff --git a/scripts/disk.sh b/legacy/scripts/disk.sh
similarity index 100%
rename from scripts/disk.sh
rename to legacy/scripts/disk.sh
diff --git a/scripts/docker.sh b/legacy/scripts/docker.sh
similarity index 100%
rename from scripts/docker.sh
rename to legacy/scripts/docker.sh
diff --git a/scripts/memory.sh b/legacy/scripts/memory.sh
similarity index 100%
rename from scripts/memory.sh
rename to legacy/scripts/memory.sh
diff --git a/scripts/moc_status.sh b/legacy/scripts/moc_status.sh
similarity index 100%
rename from scripts/moc_status.sh
rename to legacy/scripts/moc_status.sh
diff --git a/scripts/rofi/rofi-openvpn.sh b/legacy/scripts/rofi/rofi-openvpn.sh
similarity index 100%
rename from scripts/rofi/rofi-openvpn.sh
rename to legacy/scripts/rofi/rofi-openvpn.sh
diff --git a/scripts/rofi/rofi-ssh.sh b/legacy/scripts/rofi/rofi-ssh.sh
similarity index 100%
rename from scripts/rofi/rofi-ssh.sh
rename to legacy/scripts/rofi/rofi-ssh.sh
diff --git a/setup.sh b/legacy/setup.sh
similarity index 100%
rename from setup.sh
rename to legacy/setup.sh
diff --git a/shell/.archive/wmfsrc b/legacy/shell/.archive/wmfsrc
similarity index 100%
rename from shell/.archive/wmfsrc
rename to legacy/shell/.archive/wmfsrc
diff --git a/shell/.bashrc b/legacy/shell/.bashrc
similarity index 100%
rename from shell/.bashrc
rename to legacy/shell/.bashrc
diff --git a/shell/.tmux.conf b/legacy/shell/.tmux.conf
similarity index 100%
rename from shell/.tmux.conf
rename to legacy/shell/.tmux.conf
diff --git a/shell/.vimrc b/legacy/shell/.vimrc
similarity index 100%
rename from shell/.vimrc
rename to legacy/shell/.vimrc
diff --git a/shell/.xinitrc b/legacy/shell/.xinitrc
similarity index 100%
rename from shell/.xinitrc
rename to legacy/shell/.xinitrc
diff --git a/shell/.zimrc b/legacy/shell/.zimrc
similarity index 100%
rename from shell/.zimrc
rename to legacy/shell/.zimrc
diff --git a/shell/.zshrc b/legacy/shell/.zshrc
similarity index 100%
rename from shell/.zshrc
rename to legacy/shell/.zshrc