@sebastienrousseau/dotfiles 0.2.511 → 0.2.513

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +2 -2
  3. package/docs/AI.md +2 -1
  4. package/docs/COPYRIGHT +1 -1
  5. package/docs/architecture/AI_COST_OPTIMIZATION.md +1 -0
  6. package/docs/archive/LEGACY_ROADMAP.md +4 -158
  7. package/docs/manual/00-introduction.md +1 -1
  8. package/docs/manual/01-concepts/02-trust-model.md +2 -1
  9. package/docs/manual/02-tutorials/02-add-wallpaper.md +6 -6
  10. package/docs/manual/02-tutorials/03-create-profile.md +1 -1
  11. package/docs/manual/02-tutorials/05-deploy-fleet.md +1 -1
  12. package/docs/manual/03-reference/02-config-files.md +1 -1
  13. package/docs/manual/03-reference/03-environment.md +1 -1
  14. package/docs/manual/04-cookbook/03-faq.md +1 -1
  15. package/docs/manual/05-appendices/B-security-checklist.md +2 -1
  16. package/docs/manual/command-index.md +1 -0
  17. package/docs/operations/ARCHITECTURE_ROADMAP.md +5 -143
  18. package/docs/operations/COVERAGE.md +30 -8
  19. package/docs/operations/REGISTRY.md +20 -19
  20. package/docs/operations/ROADMAP.md +3 -159
  21. package/docs/operations/ROADMAP_2026.md +5 -663
  22. package/docs/operations/ROADMAP_V0_2_503.md +5 -129
  23. package/docs/reference/POWERSHELL_PARITY.md +28 -27
  24. package/docs/reference/THEMES.md +1 -1
  25. package/docs/reference/TOOLS.md +1 -0
  26. package/docs/reference/UTILS.md +1 -0
  27. package/docs/schema/dot-registry-v1.json +33 -0
  28. package/docs/security/SCORECARD.md +1 -1
  29. package/install.sh +20 -11
  30. package/package.json +1 -1
  31. package/scripts/diagnostics/a2a-conformance.sh +0 -0
  32. package/scripts/diagnostics/aliases-manifest.sh +33 -6
  33. package/scripts/diagnostics/benchmark.sh +27 -1
  34. package/scripts/diagnostics/doctor.sh +18 -11
  35. package/scripts/diagnostics/health.sh +12 -4
  36. package/scripts/diagnostics/mcp-doctor.sh +3 -0
  37. package/scripts/diagnostics/secret-governance.sh +7 -1
  38. package/scripts/diagnostics/security-score.sh +14 -5
  39. package/scripts/diagnostics/verify_state.sh +9 -1
  40. package/scripts/diagnostics/workstation-attestation.sh +0 -0
  41. package/scripts/dot/commands/ai.sh +40 -31
  42. package/scripts/dot/commands/aliases.sh +28 -4
  43. package/scripts/dot/commands/appearance.sh +16 -0
  44. package/scripts/dot/commands/core.sh +17 -0
  45. package/scripts/dot/commands/diagnostics.sh +19 -0
  46. package/scripts/dot/commands/fleet.sh +1 -1
  47. package/scripts/dot/commands/manual.sh +4 -4
  48. package/scripts/dot/commands/meta.sh +87 -12
  49. package/scripts/dot/commands/registry.sh +164 -15
  50. package/scripts/dot/commands/secrets.sh +17 -0
  51. package/scripts/dot/commands/security.sh +17 -0
  52. package/scripts/dot/commands/tools.sh +19 -0
  53. package/scripts/dot/powershell/Dot.psm1 +146 -26
  54. package/scripts/fonts/install-nerd-fonts.sh +16 -10
  55. package/scripts/git-hooks/pre-commit-audit.sh +1 -1
  56. package/scripts/ops/ai-setup.sh +13 -6
  57. package/scripts/ops/bundle.sh +31 -5
  58. package/scripts/ops/chezmoi-apply.sh +5 -0
  59. package/scripts/ops/heal-tools.sh +39 -111
  60. package/scripts/ops/post-apply-repair.sh +0 -0
  61. package/scripts/ops/release.sh +14 -5
  62. package/scripts/qa/docs-coverage.sh +1 -1
  63. package/scripts/qa/reliability-audit.sh +4 -4
  64. package/scripts/qa/scorecard-snapshot.sh +3 -3
  65. package/scripts/qa/validate-examples.sh +0 -0
  66. package/scripts/qa/wsl-contract.sh +0 -0
  67. package/scripts/secrets/age-init.sh +15 -4
  68. package/scripts/theme/apply-gnome-theme.sh +6 -2
  69. package/scripts/theme/extract-theme.py +97 -33
  70. package/scripts/theme/rebuild-themes.sh +240 -35
  71. package/scripts/theme/switch.sh +26 -16
  72. package/scripts/theme/wallpaper-sync.sh +95 -7
  73. package/scripts/tools/detect-collisions.py +0 -0
  74. package/scripts/version-sync.sh +108 -44
@@ -124,51 +124,99 @@ discover_linux_system() {
124
124
  /usr/share/wallpapers
125
125
  )
126
126
 
127
- local dir file name
127
+ local dir file name variant
128
128
  for dir in "${dirs[@]}"; do
129
129
  [[ -d "$dir" ]] || continue
130
130
  while IFS= read -r file; do
131
131
  name="$(basename "$file")"
132
132
  name="${name%.*}"
133
- name="$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-')"
133
+ name="$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' _' '--')"
134
134
  name="${name//[^a-z0-9-]/}"
135
- # Avoid duplicates first found wins for system
136
- if [[ -z "${WALLPAPERS[$name]+x}" ]]; then
137
- WALLPAPERS["$name"]="$file"
138
- WP_SOURCE["$name"]="system"
135
+ while [[ "$name" == *--* ]]; do name="${name//--/-}"; done
136
+ name="${name#-}"
137
+ name="${name%-}"
138
+ [[ -n "$name" ]] || continue
139
+ # Static system images carry no mode; theme them in both modes so
140
+ # they show as a pair (unless the file is already a -dark/-light).
141
+ if [[ "$name" == *-dark || "$name" == *-light ]]; then
142
+ [[ -z "${WALLPAPERS[$name]+x}" ]] && {
143
+ WALLPAPERS["$name"]="$file"
144
+ WP_SOURCE["$name"]="system"
145
+ }
146
+ continue
139
147
  fi
140
- done < <(find "$dir" -maxdepth 3 -type f \( -name "*.jpg" -o -name "*.png" -o -name "*.heic" \) 2>/dev/null | sort)
148
+ local key
149
+ for variant in dark light; do
150
+ key="${name}-${variant}"
151
+ if [[ -z "${WALLPAPERS[$key]+x}" ]]; then
152
+ WALLPAPERS["$key"]="$file"
153
+ WP_SOURCE["$key"]="system"
154
+ fi
155
+ done
156
+ done < <(find "$dir" -maxdepth 3 -type f \
157
+ \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' \
158
+ -o -iname '*.heic' -o -iname '*.webp' -o -iname '*.tiff' \) 2>/dev/null | sort)
141
159
  done
142
160
  }
143
161
 
144
162
  discover_custom() {
145
163
  [[ -d "$CUSTOM_DIR" ]] || return 0
146
164
 
147
- local file name frame_count
148
- for file in "$CUSTOM_DIR"/*.heic "$CUSTOM_DIR"/*.jpg "$CUSTOM_DIR"/*.png; do
165
+ local file base name ext frame_count
166
+ # Case-insensitive, wide extension match. The old fixed-glob loop
167
+ # (`*.heic *.jpg *.png`) silently dropped `.jpeg`, `.webp`, `.tiff`
168
+ # and every uppercase variant (`.JPG`, `.HEIC`, …), so those
169
+ # wallpapers never made it into the theme table.
170
+ while IFS= read -r file; do
149
171
  [[ -f "$file" ]] || continue
150
- name="$(basename "$file")"
151
- name="${name%.*}"
172
+ base="$(basename "$file")"
173
+ base="${base%.*}"
174
+ ext="$(echo "${file##*.}" | tr '[:upper:]' '[:lower:]')"
175
+
176
+ # Normalize to the [a-z0-9-] namespace that themes.toml and the
177
+ # `dot theme list` picker regex both require — a raw name with
178
+ # spaces/uppercase would generate a section the picker can't match.
179
+ name="$(echo "$base" | tr '[:upper:]' '[:lower:]' | tr ' _' '--')"
180
+ name="${name//[^a-z0-9-]/}"
181
+ while [[ "$name" == *--* ]]; do name="${name//--/-}"; done
182
+ name="${name#-}"
183
+ name="${name%-}"
184
+ [[ -n "$name" ]] || continue
152
185
 
153
- # Check if this is a dynamic HEIC (multi-frame = light+dark in one file)
154
- if [[ "${file##*.}" == "heic" ]]; then
186
+ # Dynamic HEIC (multi-frame = light+dark packed in one file).
187
+ if [[ "$ext" == "heic" ]]; then
155
188
  frame_count="$(magick identify "$file" 2>/dev/null | wc -l | tr -d ' ')"
156
189
  if [[ "$frame_count" -ge 2 && "$name" != *-dark && "$name" != *-light ]]; then
157
- # Dynamic HEIC: register as both dark and light
158
190
  WALLPAPERS["${name}-light"]="${file}[0]"
159
191
  WP_SOURCE["${name}-light"]="custom"
160
192
  WALLPAPERS["${name}-dark"]="${file}[1]"
161
193
  WP_SOURCE["${name}-dark"]="custom"
162
- # Store the original file path for wallpaper-sync
194
+ # Store the original file path for wallpaper-sync.
163
195
  WALLPAPERS["${name}"]="$file"
164
196
  WP_SOURCE["${name}"]="custom-dynamic"
165
197
  continue
166
198
  fi
167
199
  fi
168
200
 
169
- WALLPAPERS["$name"]="$file"
170
- WP_SOURCE["$name"]="custom"
171
- done
201
+ # Explicitly-named single-mode variant (foo-dark.jpg / foo-light.jpg):
202
+ # honour the author's mode, register the one variant as-is.
203
+ if [[ "$name" == *-dark || "$name" == *-light ]]; then
204
+ WALLPAPERS["$name"]="$file"
205
+ WP_SOURCE["$name"]="custom"
206
+ continue
207
+ fi
208
+
209
+ # Static single image: theme it in BOTH modes (extract-theme forces
210
+ # the mode from the -dark/-light suffix) so it appears as a light/dark
211
+ # pair in `dot theme list`. Previously a static produced only one
212
+ # variant and paired_families() hid it entirely.
213
+ WALLPAPERS["${name}-dark"]="$file"
214
+ WP_SOURCE["${name}-dark"]="custom"
215
+ WALLPAPERS["${name}-light"]="$file"
216
+ WP_SOURCE["${name}-light"]="custom"
217
+ done < <(find "$CUSTOM_DIR" -maxdepth 1 -type f \
218
+ \( -iname '*.heic' -o -iname '*.jpg' -o -iname '*.jpeg' \
219
+ -o -iname '*.png' -o -iname '*.webp' -o -iname '*.tiff' \) 2>/dev/null | sort)
172
220
  }
173
221
 
174
222
  # Remove dynamic base entries (keep only the -dark/-light variants for theme gen)
@@ -181,11 +229,15 @@ cleanup_dynamic_entries() {
181
229
  done
182
230
  }
183
231
 
184
- # Discover in order: system first, custom overrides
185
- case "$(uname -s)" in
186
- Darwin) discover_macos_system ;;
187
- Linux) discover_linux_system ;;
188
- esac
232
+ # Discover in order: system first, custom overrides. System (OS-shipped)
233
+ # wallpapers are opt-in — most users only want themes from their own
234
+ # wallpapers. Enable the ~100 built-in ones with DOTFILES_THEME_SYSTEM=1.
235
+ if [[ "${DOTFILES_THEME_SYSTEM:-0}" == "1" ]]; then
236
+ case "$(uname -s)" in
237
+ Darwin) discover_macos_system ;;
238
+ Linux) discover_linux_system ;;
239
+ esac
240
+ fi
189
241
  discover_custom
190
242
  cleanup_dynamic_entries
191
243
 
@@ -238,16 +290,29 @@ done
238
290
  # Generate themes
239
291
  # ---------------------------------------------------------------------------
240
292
 
241
- sys_count=0
242
- cust_count=0
293
+ # Report distinct WALLPAPERS (families), not theme sections. Every
294
+ # wallpaper — a dynamic HEIC (light+dark frames) or a static image —
295
+ # yields a `-light` and a `-dark` theme, so a raw section count is ~2x
296
+ # the file count and reads as wrong ("74 files → 148 custom"). Collapse
297
+ # -dark/-light back to the family so the numbers match what's on disk
298
+ # and what `dot theme list` shows, and report the theme total separately.
299
+ # Explicit empty init: a `declare -A x` that never gets a key still trips
300
+ # `set -u` on ${#x[@]} (even in bash 5) — happens now that system wallpapers
301
+ # are opt-in and sys_fam can stay empty.
302
+ declare -A sys_fam=() cust_fam=()
243
303
  for name in "${!WP_SOURCE[@]}"; do
304
+ family="${name%-dark}"
305
+ family="${family%-light}"
244
306
  case "${WP_SOURCE[$name]}" in
245
- system) sys_count=$((sys_count + 1)) ;;
246
- custom) cust_count=$((cust_count + 1)) ;;
307
+ system) sys_fam["$family"]=1 ;;
308
+ custom) cust_fam["$family"]=1 ;;
247
309
  esac
248
310
  done
311
+ sys_count=${#sys_fam[@]}
312
+ cust_count=${#cust_fam[@]}
249
313
  echo "Discovering wallpapers..."
250
- echo " Found: $sys_count system, $cust_count custom (${#WALLPAPERS[@]} total)"
314
+ echo " Found: $sys_count system, $cust_count custom wallpapers" \
315
+ "($((sys_count + cust_count)) total → ${#WALLPAPERS[@]} light/dark themes)"
251
316
  echo ""
252
317
 
253
318
  GENERATED=0
@@ -297,11 +362,20 @@ if [[ $TOTAL_WORK -gt 0 ]]; then
297
362
  wait
298
363
  fi
299
364
 
300
- # Count results
301
- GENERATED=$(find "$CACHE_DIR" -name "*.toml" -newer "$0" 2>/dev/null | wc -l | tr -d ' ')
302
- FAILED=$((TOTAL_WORK - GENERATED))
365
+ # Count results — how many of THIS run's work items produced a cache file.
366
+ # (The old `find -newer "$0"` counted every cache file newer than the script,
367
+ # so when everything was cached FAILED went negative.)
368
+ GENERATED=0
369
+ FAILED=0
370
+ for name in "${WORK[@]}"; do
371
+ if [[ -f "$CACHE_DIR/${name}.toml" ]]; then
372
+ GENERATED=$((GENERATED + 1))
373
+ else
374
+ FAILED=$((FAILED + 1))
375
+ fi
376
+ done
303
377
  echo ""
304
- echo "Results: $TOTAL_WORK processed, $CACHED cached, $FAILED failed"
378
+ echo "Results: $GENERATED generated, $CACHED cached, $FAILED failed"
305
379
 
306
380
  # ---------------------------------------------------------------------------
307
381
  # Assemble themes.toml
@@ -326,14 +400,145 @@ echo "Assembling themes.toml..."
326
400
 
327
401
  HEADER
328
402
 
329
- for cache_file in "$CACHE_DIR"/*.toml; do
403
+ # Assemble ONLY the wallpapers discovered this run, not every file left in
404
+ # the cache. This drops themes for wallpapers no longer present (e.g. system
405
+ # wallpapers once DOTFILES_THEME_SYSTEM is turned off) instead of letting
406
+ # stale cache entries pile up in themes.toml.
407
+ for name in $(printf '%s\n' "${!WALLPAPERS[@]}" | sort); do
408
+ cache_file="$CACHE_DIR/${name}.toml"
330
409
  [[ -f "$cache_file" ]] || continue
331
410
  echo ""
332
411
  cat "$cache_file"
333
412
  done
413
+
414
+ # Synthetic fallback themes — always emitted, never wallpaper-derived.
415
+ # Every theme template degrades to `fallback-dark` when `.theme` is unset or
416
+ # names a theme absent from this file. Because these are re-emitted on every
417
+ # rebuild, a regeneration that drops any wallpaper theme can never break the
418
+ # fallback (unlike hardcoding a wallpaper theme like the old big-sur-dark).
419
+ # switch.sh hides the `fallback` family from user-facing theme lists.
420
+ cat <<'FALLBACK'
421
+
422
+ # ─────────────────────────────────────────────────────────────────────
423
+ # Synthetic fallback themes (NOT wallpaper-derived, NEVER user-selectable).
424
+ # ─────────────────────────────────────────────────────────────────────
425
+
426
+ [themes.fallback-dark]
427
+ mode = "dark"
428
+ family = "fallback"
429
+ macos_accent = 4
430
+ wallpaper = ""
431
+ source = "custom"
432
+
433
+ [themes.fallback-dark.term]
434
+ bg = "#1e1e2e"
435
+ fg = "#d4d4d4"
436
+ cursor = "#455c67"
437
+ cursor_text = "#1e1e2e"
438
+ sel_bg = "#394145"
439
+ sel_fg = "#d4d4d4"
440
+ c0 = "#3c3c4d"
441
+ c1 = "#997d7a"
442
+ c2 = "#479174"
443
+ c3 = "#8a836f"
444
+ c4 = "#5f86b7"
445
+ c5 = "#917e8e"
446
+ c6 = "#5c8d82"
447
+ c7 = "#b9b8bb"
448
+ c8 = "#605f72"
449
+ c9 = "#bf9b97"
450
+ c10 = "#67b293"
451
+ c11 = "#aba389"
452
+ c12 = "#7fa6d9"
453
+ c13 = "#b49cb0"
454
+ c14 = "#7bada1"
455
+ c15 = "#e2e2e3"
456
+
457
+ [themes.fallback-dark.ui]
458
+ accent = "#455c67"
459
+ accent_text = "#ffffff"
460
+ error = "#997d7a"
461
+ warning = "#8a836f"
462
+ success = "#479174"
463
+ info = "#5f86b7"
464
+ panel = "#242435"
465
+ border = "#302f38"
466
+
467
+ [themes.fallback-dark.app]
468
+ nvim = "tokyonight"
469
+ nvim_style = "night"
470
+ lualine = "tokyonight"
471
+ gtk_theme = "Adwaita-dark"
472
+ gtk_icon = "Papirus-Dark"
473
+ gnome_shell = ""
474
+ gnome_gtk = "Adwaita-dark"
475
+ vscode = "Tokyonight Mocha"
476
+ vscode_dark = "Tokyonight Mocha"
477
+ vscode_light = "Tokyonight Latte"
478
+ cat_wallpaper = ""
479
+ starship_palette = "catppuccin_mocha"
480
+
481
+
482
+ [themes.fallback-light]
483
+ mode = "light"
484
+ family = "fallback"
485
+ macos_accent = 4
486
+ wallpaper = ""
487
+ source = "custom"
488
+
489
+ [themes.fallback-light.term]
490
+ bg = "#fbf1c7"
491
+ fg = "#3c3836"
492
+ cursor = "#2c5989"
493
+ cursor_text = "#fbf1c7"
494
+ sel_bg = "#c6cfe0"
495
+ sel_fg = "#3c3836"
496
+ c0 = "#2e2c26"
497
+ c1 = "#7d3d39"
498
+ c2 = "#205a48"
499
+ c3 = "#5b501d"
500
+ c4 = "#2b527d"
501
+ c5 = "#6e4069"
502
+ c6 = "#25554c"
503
+ c7 = "#6f6d67"
504
+ c8 = "#54524b"
505
+ c9 = "#853733"
506
+ c10 = "#025c46"
507
+ c11 = "#5b500b"
508
+ c12 = "#0d5388"
509
+ c13 = "#743b6f"
510
+ c14 = "#15564b"
511
+ c15 = "#979693"
512
+
513
+ [themes.fallback-light.ui]
514
+ accent = "#2c5989"
515
+ accent_text = "#ffffff"
516
+ error = "#7d3d39"
517
+ warning = "#5b501d"
518
+ success = "#205a48"
519
+ info = "#2b527d"
520
+ panel = "#f2e8bf"
521
+ border = "#e3e0d3"
522
+
523
+ [themes.fallback-light.app]
524
+ nvim = "catppuccin"
525
+ nvim_style = "latte"
526
+ lualine = "catppuccin"
527
+ gtk_theme = "Adwaita"
528
+ gtk_icon = "Papirus-Light"
529
+ gnome_shell = ""
530
+ gnome_gtk = "Adwaita"
531
+ vscode = "Catppuccin Latte"
532
+ vscode_dark = "Catppuccin Mocha"
533
+ vscode_light = "Catppuccin Latte"
534
+ cat_wallpaper = ""
535
+ starship_palette = "catppuccin_latte"
536
+ FALLBACK
334
537
  } >"$THEMES_FILE"
335
538
 
336
- theme_count=$(grep -c '^\[themes\.' "$THEMES_FILE" | head -1)
337
- echo " Written: $THEMES_FILE ($theme_count theme sections)"
539
+ # Count top-level [themes.NAME] blocks only not the .term/.ui/.app
540
+ # subsections (which inflated the tally ~4x).
541
+ theme_count=$(grep -cE '^\[themes\.[a-z0-9-]+\]$' "$THEMES_FILE")
542
+ echo " Written: $THEMES_FILE ($theme_count themes)"
338
543
  echo ""
339
544
  echo "Done. Run 'dot theme list' to see available themes."
@@ -78,15 +78,33 @@ fi
78
78
  # Theme Database
79
79
  # =============================================================================
80
80
 
81
- # Default preferences
82
- DEFAULT_DARK="macos-monterey-dark"
83
- DEFAULT_LIGHT="macos-monterey-light"
81
+ # Default preferences (must be present keys in themes.toml — the theme
82
+ # regeneration removed the old macos-monterey-* names; bloom is the current
83
+ # default family and always ships a dark+light pair).
84
+ DEFAULT_DARK="bloom-dark"
85
+ DEFAULT_LIGHT="bloom-light"
86
+
87
+ # Machine-local chezmoi override (chezmoi.toml [data] theme) takes precedence
88
+ # over .chezmoidata.toml when rendering, so it is the authoritative "current"
89
+ # value. Kept in sync by dot-theme-sync's write_theme().
90
+ CHEZMOI_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/chezmoi/chezmoi.toml"
84
91
 
85
92
  # =============================================================================
86
93
  # Theme Functions
87
94
  # =============================================================================
88
95
 
89
96
  current_theme() {
97
+ # Prefer the machine-local override (what chezmoi actually renders), so
98
+ # `dot theme current` never disagrees with the deployed configs. Fall back
99
+ # to the repo default in .chezmoidata.toml when no override is set.
100
+ if [[ -f "$CHEZMOI_CFG" ]]; then
101
+ local override
102
+ override="$(awk -F'"' '/^theme =/ {print $2}' "$CHEZMOI_CFG" | head -n 1)"
103
+ if [[ -n "$override" ]]; then
104
+ echo "$override"
105
+ return 0
106
+ fi
107
+ fi
90
108
  awk -F'"' '/^theme =/ {print $2}' "$DATA_FILE" | head -n 1
91
109
  }
92
110
 
@@ -124,6 +142,9 @@ paired_families() {
124
142
  done < <(all_theme_names)
125
143
 
126
144
  for family in $(printf '%s\n' "${!has_dark[@]}" | sort); do
145
+ # `fallback` is a synthetic safety theme (see themes.toml) that templates
146
+ # degrade to when .theme is unset/invalid — never a user-selectable one.
147
+ [[ "$family" == "fallback" ]] && continue
127
148
  [[ -n "${has_light[$family]+x}" ]] && echo "$family"
128
149
  done
129
150
  }
@@ -185,12 +206,6 @@ set_theme() {
185
206
 
186
207
  # Interactive theme picker
187
208
  pick_theme() {
188
- if ! command -v fzf &>/dev/null; then
189
- ui_err "fzf" "required for interactive picker"
190
- ui_info "Usage" "dot theme set <name>"
191
- exit 1
192
- fi
193
-
194
209
  local current
195
210
  current="$(current_theme)"
196
211
 
@@ -220,14 +235,9 @@ pick_theme() {
220
235
  done < <(paired_families)
221
236
 
222
237
  local selected_family
223
- selected_family="$(echo "$theme_list" | fzf \
238
+ selected_family="$(printf '%s' "$theme_list" | ui_pick \
224
239
  --header "Select wallpaper theme (current: $current_family [$current_mode])" \
225
- --prompt "Theme > " \
226
- --height 30 \
227
- --reverse \
228
- --no-sort \
229
- --no-preview \
230
- --ansi |
240
+ --prompt "Theme >" |
231
241
  awk '$1 !~ /^#/ && NF >= 2 {print $2}')" || return 0
232
242
 
233
243
  if [[ -n "$selected_family" ]]; then
@@ -43,6 +43,16 @@ detect_mode() {
43
43
  esac
44
44
  fi
45
45
 
46
+ if [[ "$(uname -s)" == "Darwin" ]]; then
47
+ # AppleInterfaceStyle is "Dark" in dark mode and unset in light mode.
48
+ if [[ "$(defaults read -g AppleInterfaceStyle 2>/dev/null || true)" == "Dark" ]]; then
49
+ echo "dark"
50
+ else
51
+ echo "light"
52
+ fi
53
+ return 0
54
+ fi
55
+
46
56
  if command -v gsettings &>/dev/null; then
47
57
  local scheme
48
58
  scheme="$(gsettings get org.gnome.desktop.interface color-scheme 2>/dev/null || echo "")"
@@ -125,10 +135,16 @@ wallpaper_for_theme() {
125
135
  found && /^wallpaper/ { sub(/.*= *"/, ""); sub(/".*/, ""); print; exit }
126
136
  ' "$themes_file")"
127
137
  if [[ -n "$stored_wp" ]]; then
128
- # Cross-platform: resolve macOS paths on Linux
138
+ # Home-relative form (current generator): ~/Pictures/... → $HOME/Pictures/...
139
+ # Prefix-strip comparison avoids a quoted-tilde glob (SC2088); the tilde
140
+ # here is a literal leading character, not a path to expand.
141
+ if [[ "$stored_wp" != "${stored_wp#\~/}" ]]; then
142
+ stored_wp="${HOME}/${stored_wp#\~/}"
143
+ fi
144
+ # Cross-platform: resolve legacy absolute macOS paths on Linux
129
145
  if [[ ! -f "$stored_wp" ]]; then
130
146
  if [[ "$stored_wp" == /Users/* ]]; then
131
- # /Users/<user>/Pictures/... → $HOME/Pictures/...
147
+ # /Users/<user>/Pictures/... → $HOME/Pictures/... (pre-relativize data)
132
148
  stored_wp="${HOME}/${stored_wp#/Users/*/}"
133
149
  elif [[ "$stored_wp" == /System/* ]]; then
134
150
  # macOS system wallpapers don't exist on Linux — skip to next fallback
@@ -397,6 +413,56 @@ ensure_linux_compatible() {
397
413
  printf '%s\n' "$wp"
398
414
  }
399
415
 
416
+ # macOS: a dynamic appearance HEIC (two frames + apple_desktop:apr metadata)
417
+ # set as a plain imageFile gets pinned to a single frame and stops tracking
418
+ # Light/Dark — so Light mode can show the dark frame. Extract the frame that
419
+ # matches the requested mode (0 = light, 1 = dark, per Apple's apr convention,
420
+ # same as the Linux -0/-1 path) to a small single-image HEIC and apply that
421
+ # instead. Single-image HEICs and non-HEICs are returned unchanged.
422
+ macos_appearance_frame() {
423
+ local wp="$1" mode="$2"
424
+ [[ "${wp##*.}" == "heic" ]] || {
425
+ printf '%s\n' "$wp"
426
+ return
427
+ }
428
+ command -v magick &>/dev/null || {
429
+ printf '%s\n' "$wp"
430
+ return
431
+ }
432
+ local frames
433
+ frames="$(magick identify "$wp" 2>/dev/null | wc -l | tr -d ' ')"
434
+ [[ "${frames:-0}" -ge 2 ]] || {
435
+ printf '%s\n' "$wp"
436
+ return
437
+ }
438
+
439
+ local idx=0
440
+ [[ "$mode" == "dark" ]] && idx=1
441
+ local cache_dir="$WALLPAPER_DIR/.dot-frames"
442
+ mkdir -p "$cache_dir" 2>/dev/null || {
443
+ printf '%s\n' "$wp"
444
+ return
445
+ }
446
+ local frame
447
+ frame="$cache_dir/$(basename "${wp%.heic}")-${mode}.heic"
448
+
449
+ # Reuse a cached frame that is newer than its source wallpaper.
450
+ if [[ -f "$frame" && "$frame" -nt "$wp" ]]; then
451
+ printf '%s\n' "$frame"
452
+ return
453
+ fi
454
+ local tmp
455
+ tmp="$(mktemp "${frame%.heic}.XXXXXX.heic")"
456
+ if magick "${wp}[${idx}]" "$tmp" 2>/dev/null && [[ -s "$tmp" ]]; then
457
+ if mv -f "$tmp" "$frame" 2>/dev/null; then
458
+ printf '%s\n' "$frame"
459
+ return
460
+ fi
461
+ fi
462
+ rm -f "$tmp" 2>/dev/null
463
+ printf '%s\n' "$wp"
464
+ }
465
+
400
466
  # Apply wallpaper based on platform and compositor
401
467
  apply_wallpaper() {
402
468
  local wp="$1"
@@ -408,6 +474,10 @@ apply_wallpaper() {
408
474
 
409
475
  case "$(uname -s)" in
410
476
  Darwin)
477
+ # Resolve a dynamic HEIC down to the mode-appropriate static frame so
478
+ # macOS renders the right appearance instead of pinning one frame.
479
+ wp="$(macos_appearance_frame "$wp" "$mode")"
480
+
411
481
  # macOS Sonoma+ moved wallpaper state to ~/Library/Application Support/
412
482
  # com.apple.wallpaper/Store/Index.plist, owned by WallpaperAgent. Each
413
483
  # Space and Display has its own entry, and AppleScript's `every desktop`
@@ -494,12 +564,29 @@ if updated:
494
564
  print(updated)
495
565
  PYEOF
496
566
 
497
- # Restart WallpaperAgent so it re-reads the store. launchd respawns it.
498
- killall WallpaperAgent 2>/dev/null || true
567
+ # Restart WallpaperAgent so it re-reads the store and repaints EVERY
568
+ # Space (the store rewrite above covers all Spaces; the agent only
569
+ # applies it on respawn). Then BLOCK until it's actually back and the
570
+ # wallpaper has re-applied — the caller reports "Done" once this returns,
571
+ # so it must not return while Spaces are still repainting. Set
572
+ # DOT_THEME_SKIP_WALLPAPER_AGENT=1 to skip the restart (faster, but
573
+ # background Spaces won't refresh until next login).
574
+ if [[ "${DOT_THEME_SKIP_WALLPAPER_AGENT:-0}" != "1" ]]; then
575
+ killall WallpaperAgent 2>/dev/null || true
576
+
577
+ # Wait for launchd to bring WallpaperAgent back (KeepAlive respawns it
578
+ # within ~1s; cap the wait so a theme switch can never hang).
579
+ local _waited=0
580
+ while ! pgrep -x WallpaperAgent >/dev/null 2>&1; do
581
+ sleep 0.1
582
+ _waited=$((_waited + 1))
583
+ [[ "$_waited" -ge 60 ]] && break
584
+ done
585
+ fi
499
586
 
500
- # Also nudge the active Space via the public API. This covers the
501
- # initial render before WallpaperAgent comes back, and handles screens
502
- # the Index.plist rewrite somehow missed.
587
+ # Re-assert the wallpaper through the (now running) agent covers the
588
+ # active Space's first paint and any screen the store rewrite missed —
589
+ # then give that paint a moment to finish before returning.
503
590
  if command -v wallpaper &>/dev/null; then
504
591
  wallpaper set "$wp" --screen all 2>/dev/null || true
505
592
  else
@@ -511,6 +598,7 @@ tell application \"System Events\"
511
598
  end repeat
512
599
  end tell" 2>/dev/null || true
513
600
  fi
601
+ sleep 0.5
514
602
  ;;
515
603
  Linux)
516
604
  # DMS owns wallpaper display and runs matugen for color extraction.
File without changes