@sebastienrousseau/dotfiles 0.2.510 → 0.2.512

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 (64) hide show
  1. package/CHANGELOG.md +54 -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/articles/.pages +6 -0
  8. package/docs/articles/2026-07-05-custom-mkdocs-material-dark-theme.md +216 -0
  9. package/docs/articles/2026-07-05-fish-startup-abbr.md +153 -0
  10. package/docs/articles/2026-07-05-master-to-main-rename-runbook.md +128 -0
  11. package/docs/articles/index.md +36 -0
  12. package/docs/manual/00-introduction.md +1 -1
  13. package/docs/manual/02-tutorials/02-add-wallpaper.md +6 -6
  14. package/docs/manual/02-tutorials/03-create-profile.md +1 -1
  15. package/docs/manual/02-tutorials/05-deploy-fleet.md +1 -1
  16. package/docs/manual/03-reference/02-config-files.md +1 -1
  17. package/docs/manual/command-index.md +1 -0
  18. package/docs/operations/ARCHITECTURE_ROADMAP.md +5 -143
  19. package/docs/operations/COVERAGE.md +27 -4
  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/THEMES.md +1 -1
  24. package/docs/reference/TOOLS.md +1 -0
  25. package/docs/reference/UTILS.md +1 -0
  26. package/install.sh +5 -5
  27. package/package.json +1 -1
  28. package/scripts/diagnostics/aliases-manifest.sh +52 -7
  29. package/scripts/diagnostics/benchmark.sh +27 -1
  30. package/scripts/diagnostics/doctor.sh +18 -11
  31. package/scripts/diagnostics/health.sh +8 -3
  32. package/scripts/diagnostics/mcp-doctor.sh +3 -0
  33. package/scripts/diagnostics/secret-governance.sh +7 -1
  34. package/scripts/diagnostics/security-score.sh +14 -5
  35. package/scripts/diagnostics/verify_state.sh +15 -3
  36. package/scripts/diagnostics/version-locks.sh +10 -6
  37. package/scripts/dot/commands/agent.sh +5 -1
  38. package/scripts/dot/commands/ai.sh +37 -30
  39. package/scripts/dot/commands/aliases.sh +28 -4
  40. package/scripts/dot/commands/appearance.sh +16 -0
  41. package/scripts/dot/commands/core.sh +17 -0
  42. package/scripts/dot/commands/diagnostics.sh +19 -0
  43. package/scripts/dot/commands/fleet.sh +4 -1
  44. package/scripts/dot/commands/manual.sh +4 -4
  45. package/scripts/dot/commands/meta.sh +87 -12
  46. package/scripts/dot/commands/registry.sh +8 -2
  47. package/scripts/dot/commands/secrets.sh +17 -0
  48. package/scripts/dot/commands/security.sh +17 -0
  49. package/scripts/dot/commands/tools.sh +30 -2
  50. package/scripts/git-hooks/pre-commit-audit.sh +1 -1
  51. package/scripts/ops/ai-setup.sh +13 -6
  52. package/scripts/ops/bundle.sh +31 -5
  53. package/scripts/ops/chezmoi-apply.sh +5 -0
  54. package/scripts/ops/release.sh +14 -5
  55. package/scripts/qa/docs-coverage.sh +1 -1
  56. package/scripts/qa/reliability-audit.sh +2 -2
  57. package/scripts/secrets/age-init.sh +15 -4
  58. package/scripts/theme/apply-gnome-theme.sh +6 -2
  59. package/scripts/theme/extract-theme.py +97 -33
  60. package/scripts/theme/rebuild-themes.sh +240 -35
  61. package/scripts/theme/switch.sh +26 -16
  62. package/scripts/theme/wallpaper-sync.sh +95 -7
  63. package/scripts/tools/detect-collisions.py +19 -1
  64. package/scripts/version-sync.sh +87 -43
@@ -199,6 +199,7 @@ _AI_PROVIDERS=(
199
199
  "copilot|npm:@github/copilot|Copilot CLI"
200
200
  "goose|native|Goose"
201
201
  "agy|native|Antigravity CLI"
202
+ "kimi|native|Kimi CLI"
202
203
  "sgpt|pipx:shell-gpt|Shell-GPT"
203
204
  "ollama|aqua:ollama/ollama|Ollama"
204
205
  "opencode|npm:opencode-ai|OpenCode"
@@ -276,6 +277,10 @@ if [[ ${#_ai_missing[@]} -gt 0 ]] && [[ "$INTERACTIVE" == "1" ]]; then
276
277
  install_agy_native "$_label"
277
278
  continue
278
279
  fi
280
+ if [[ "$_bin" == "kimi" ]]; then
281
+ install_kimi_native "$_label"
282
+ continue
283
+ fi
279
284
  if command -v gum &>/dev/null; then
280
285
  if gum spin --spinner dot --title "Installing $_label ($_pkg)" -- \
281
286
  mise use -g "$_pkg@latest" 2>&1; then
@@ -19,6 +19,8 @@ source "$SCRIPT_DIR/../../lib/dot/ui.sh"
19
19
  source "$SCRIPT_DIR/../../lib/dot/log.sh"
20
20
  export DOT_COMMAND="release"
21
21
  ui_init
22
+ BOLD="${BOLD:-}"
23
+ NC="${NC:-}"
22
24
 
23
25
  log_info() { ui_info "$@"; }
24
26
  log_success() { ui_ok "$@"; }
@@ -129,11 +131,18 @@ main() {
129
131
  done
130
132
 
131
133
  # Concurrency guard
132
- LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/dotfiles-release.lock"
133
- exec 9>"$LOCK_FILE"
134
- if ! flock -n 9; then
135
- ui_warn "Already running" "Another release is in progress"
136
- exit 0
134
+ local lock_dir
135
+ lock_dir="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
136
+ if [[ ! -d "$lock_dir" || ! -w "$lock_dir" ]]; then
137
+ lock_dir="${TMPDIR:-/tmp}"
138
+ fi
139
+ LOCK_FILE="$lock_dir/dotfiles-release.lock"
140
+ if command -v flock >/dev/null 2>&1; then
141
+ exec 9>"$LOCK_FILE"
142
+ if ! flock -n 9; then
143
+ ui_warn "Already running" "Another release is in progress"
144
+ exit 0
145
+ fi
137
146
  fi
138
147
 
139
148
  cd "$PROJECT_ROOT"
@@ -54,7 +54,7 @@ check_dot_command_docs() {
54
54
  check_ai_provider_docs() {
55
55
  local provider=""
56
56
 
57
- for provider in cl copilot agy kiro sgpt ollama opencode aider; do
57
+ for provider in cl copilot kimi agy kiro sgpt ollama opencode aider; do
58
58
  record_doc_check "dot $provider in AI.md" "\`dot $provider\`" "$AI_DOC"
59
59
  done
60
60
  }
@@ -65,12 +65,12 @@ shell_syntax() {
65
65
 
66
66
  unit_tests() {
67
67
  cd "$REPO_ROOT"
68
- ./tests/framework/test_runner.sh
68
+ ./tests/framework/test_runner.sh --jobs auto
69
69
  }
70
70
 
71
71
  integration_tests() {
72
72
  cd "$REPO_ROOT"
73
- RUN_INTEGRATION=1 ./tests/framework/test_runner.sh -i
73
+ RUN_INTEGRATION=1 ./tests/framework/test_runner.sh --jobs auto -i
74
74
  }
75
75
 
76
76
  coverage_gate() {
@@ -39,7 +39,8 @@ if config_path.exists():
39
39
 
40
40
  lines = content.splitlines()
41
41
 
42
- # Remove existing encryption/age block
42
+ # Remove existing encryption keys and age blocks regardless of where a previous
43
+ # run placed them.
43
44
  out = []
44
45
  in_age = False
45
46
  for line in lines:
@@ -57,9 +58,19 @@ for line in lines:
57
58
  continue
58
59
  out.append(line)
59
60
 
60
- # Append updated config (use json.dumps for safe string escaping)
61
- out.append("")
62
- out.append("encryption = \"age\"")
61
+ # Keep encryption at top level; chezmoi warns when it is nested under [data].
62
+ insert_at = 0
63
+ for i, line in enumerate(out):
64
+ if line.strip().startswith("["):
65
+ insert_at = i
66
+ break
67
+ else:
68
+ insert_at = len(out)
69
+
70
+ top_level = ["encryption = \"age\"", ""]
71
+ out[insert_at:insert_at] = top_level
72
+
73
+ # Append updated age config (use json.dumps for safe string escaping)
63
74
  out.append("")
64
75
  out.append("[age]")
65
76
  out.append(f"identity = {json.dumps(key_file)}")
@@ -56,7 +56,7 @@ success() {
56
56
  }
57
57
 
58
58
  # Check if we're running GNOME
59
- if [ "$XDG_CURRENT_DESKTOP" != "GNOME" ] && [ "$XDG_SESSION_DESKTOP" != "gnome" ]; then
59
+ if [ "${XDG_CURRENT_DESKTOP:-}" != "GNOME" ] && [ "${XDG_SESSION_DESKTOP:-}" != "gnome" ]; then
60
60
  warn "Not running GNOME desktop. Some settings may not apply."
61
61
  fi
62
62
 
@@ -208,7 +208,11 @@ apply_wallpaper() {
208
208
  # Pick a random wallpaper matching the mode from curated collection
209
209
  local wp=""
210
210
  if [ -d "$wallpaper_dir" ]; then
211
- wp="$(find "$wallpaper_dir" -maxdepth 1 -type f -iname "*-${mode}.jpg" 2>/dev/null | shuf -n 1 || true)"
211
+ if command -v shuf >/dev/null 2>&1; then
212
+ wp="$(find "$wallpaper_dir" -maxdepth 1 -type f -iname "*-${mode}.jpg" 2>/dev/null | shuf -n 1 || true)"
213
+ else
214
+ wp="$(find "$wallpaper_dir" -maxdepth 1 -type f -iname "*-${mode}.jpg" 2>/dev/null | sort | head -n 1 || true)"
215
+ fi
212
216
  fi
213
217
 
214
218
  # Fallback: check Catppuccin system/user wallpapers
@@ -339,42 +339,77 @@ def _nvim_from_hue(hue: float, is_dark: bool) -> Tuple[str, str]:
339
339
 
340
340
 
341
341
  def _macos_accent_from_hue(hue: float) -> int:
342
- """Map accent hue angle to macOS accent color integer."""
343
- if hue < 30 or hue >= 345:
342
+ """Map a CIELAB hue angle to a macOS accent-colour integer.
343
+
344
+ Boundaries are calibrated to the CIELAB hues of Apple's own accent
345
+ swatches (not HSL): red≈36°, orange≈67°, yellow≈87°, green≈144°,
346
+ blue≈287°, purple≈317°. The previous HSL-style cutoffs pushed blue
347
+ accents (~283°) into Purple and purple into Pink.
348
+ """
349
+ hue = hue % 360
350
+ if hue < 15 or hue >= 345:
351
+ return 6 # Pink / magenta
352
+ if hue < 50:
344
353
  return 0 # Red
345
- if hue < 60:
354
+ if hue < 77:
346
355
  return 1 # Orange
347
- if hue < 105:
356
+ if hue < 110:
348
357
  return 2 # Yellow
349
- if hue < 165:
358
+ if hue < 200:
350
359
  return 3 # Green
351
- if hue < 255:
352
- return 4 # Blue
353
360
  if hue < 300:
354
- return 5 # Purple
355
- return 6 # Pink
361
+ return 4 # Blue (incl. teal-blue and navy)
362
+ return 5 # Purple / indigo (300–345)
363
+
364
+
365
+ def _env_hex(key: str, fallback: str) -> str:
366
+ """A #rrggbb value from env, or the fallback when unset/invalid."""
367
+ v = os.environ.get(key, "")
368
+ if len(v) == 7 and v[0] == "#":
369
+ try:
370
+ hex_to_rgb(v)
371
+ return v
372
+ except ValueError:
373
+ pass
374
+ return fallback
356
375
 
357
376
 
358
377
  def _compute_bg_fg(clusters, is_dark):
359
- """Select background and foreground from clustered colors."""
360
- sorted_by_L = sorted(clusters, key=lambda c: c[0][0])
378
+ """Terminal background/foreground fixed, engineered neutrals per mode.
379
+
380
+ Deliberately NOT derived from the wallpaper: a terminal wants a
381
+ high-contrast, low-eye-strain surface that stays stable across themes.
382
+ Dark mode uses a deep neutral grey with off-white text; light mode a warm
383
+ paper cream with charcoal text. The wallpaper still drives the accent and
384
+ the 16 ANSI colours (which are computed against this bg for contrast).
385
+ Override per mode via DOTFILES_TERM_BG_DARK / _FG_DARK / _BG_LIGHT /
386
+ _FG_LIGHT.
387
+ """
388
+ _ = clusters # bg/fg no longer wallpaper-derived
361
389
  if is_dark:
362
- bg_lab = sorted_by_L[0][0]
363
- bg_lab = (min(bg_lab[0], 15.0), bg_lab[1] * 0.3, bg_lab[2] * 0.3)
364
- fg_lab = (88.0, bg_lab[1] * 0.1, bg_lab[2] * 0.1)
390
+ bg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_BG_DARK", "#1e1e2e"))
391
+ fg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_FG_DARK", "#d4d4d4"))
365
392
  else:
366
- bg_lab = sorted_by_L[-1][0]
367
- bg_lab = (max(bg_lab[0], 92.0), bg_lab[1] * 0.15, bg_lab[2] * 0.15)
368
- fg_lab = (18.0, bg_lab[1] * 0.15, bg_lab[2] * 0.15)
369
- bg_rgb = lab_to_rgb(*bg_lab)
370
- fg_rgb = ensure_contrast(lab_to_rgb(*fg_lab), bg_rgb, 7.0, is_dark)
393
+ bg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_BG_LIGHT", "#fbf1c7"))
394
+ fg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_FG_LIGHT", "#3c3836"))
395
+ bg_lab = rgb_to_lab(*bg_rgb)
371
396
  return bg_lab, bg_rgb, fg_rgb
372
397
 
373
398
 
374
399
  def _compute_accent(clusters, is_dark):
375
- """Select and AAA-compliant-darken the most saturated cluster for accent."""
376
- sorted_by_chroma = sorted(clusters, key=lambda c: lab_chroma(*c[0]), reverse=True)
377
- accent_lab = sorted_by_chroma[0][0]
400
+ """Select the accent from the wallpaper's DOMINANT chromatic colour.
401
+
402
+ Rank clusters by population x chroma so the accent follows the *main*
403
+ colour of the wallpaper (a large, colourful region) rather than the
404
+ single most-saturated cluster — which is often a tiny vivid splash
405
+ that doesn't represent the image. Near-neutral clusters score ~0 and
406
+ are skipped; if the whole image is neutral we fall back to the most
407
+ saturated cluster so the accent still carries a hue. The selected
408
+ colour is then AAA-darkened below (ADR-009 contrast requirement)."""
409
+ ranked = sorted(clusters, key=lambda c: c[1] * lab_chroma(*c[0]), reverse=True)
410
+ accent_lab = ranked[0][0]
411
+ if lab_chroma(*accent_lab) < 5.0:
412
+ accent_lab = max(clusters, key=lambda c: lab_chroma(*c[0]))[0]
378
413
  if is_dark:
379
414
  accent_lab = (max(accent_lab[0], 35.0), accent_lab[1], accent_lab[2])
380
415
  else:
@@ -412,16 +447,30 @@ def _compute_panel_border(bg_lab, bg_rgb, is_dark):
412
447
 
413
448
  def _build_ansi_color(base_lab, accent_lab, bg_rgb, is_dark):
414
449
  """Build normal + bright ANSI variant from a base Lab color."""
450
+ normal_L = (
451
+ max(55.0, min(75.0, base_lab[0])) if is_dark
452
+ else max(30.0, min(50.0, base_lab[0]))
453
+ )
454
+ normal = adjust_lightness(base_lab, normal_L)
415
455
  if is_dark:
416
- normal_L = max(55.0, min(75.0, base_lab[0]))
417
- bright_L = normal_L + 12
456
+ # Dark bg: the bright variant pops by getting lighter.
457
+ bright = adjust_lightness(base_lab, normal_L + 12)
458
+ bright_min = 4.5
418
459
  else:
419
- normal_L = max(30.0, min(50.0, base_lab[0]))
420
- bright_L = normal_L - 8
421
- normal = adjust_lightness(base_lab, normal_L)
422
- bright = adjust_lightness(base_lab, bright_L)
423
- normal_rgb = ensure_contrast(lab_to_rgb(*normal), bg_rgb, 3.0, is_dark)
424
- bright_rgb = ensure_contrast(lab_to_rgb(*bright), bg_rgb, 4.5, is_dark)
460
+ # Light bg: a lighter bright would wash out against near-white, so
461
+ # brighten by vividness at equal lightness instead — the bright is
462
+ # never darker than the normal (matches Apple's light ANSI ramp,
463
+ # where brights read as more saturated, not muddier).
464
+ bright = (normal[0], normal[1] * 1.25, normal[2] * 1.25)
465
+ bright_min = 7.0
466
+ # WCAG AAA (7:1) for the chromatic slots on a light bg so coloured text
467
+ # (paths, syntax) is unambiguously legible — AA (4.5:1) still read as washed
468
+ # out on cream. Brights stay AAA too and differ from normals by saturation
469
+ # (Apple's light ramp), not lightness. Dark mode keeps its lower floor
470
+ # (light text on dark reads comfortably at a lower ratio).
471
+ normal_min = 3.0 if is_dark else 7.0
472
+ normal_rgb = ensure_contrast(lab_to_rgb(*normal), bg_rgb, normal_min, is_dark)
473
+ bright_rgb = ensure_contrast(lab_to_rgb(*bright), bg_rgb, bright_min, is_dark)
425
474
  return normal_rgb, bright_rgb
426
475
 
427
476
 
@@ -455,11 +504,18 @@ def _structural_colors(bg_lab, bg_rgb, is_dark):
455
504
  ensure_contrast(lab_to_rgb(bg_lab[0] + 25, bg_lab[1], bg_lab[2]), bg_rgb, 2.5, True),
456
505
  ensure_contrast(lab_to_rgb(90.0, bg_lab[1] * 0.05, bg_lab[2] * 0.05), bg_rgb, 7.0, True),
457
506
  )
507
+ # Light bg: the neutral ramp increases in lightness (c0 < c8 < c7 < c15).
508
+ # c0/c8/c7 stay readable (>= AA); c15 ("bright white") is the LIGHTEST tone,
509
+ # strictly lighter than c7 — bright-white text legibility is gated by fg, not
510
+ # c15, and forcing c15 to the same floor as c7 made them converge (c15 ended
511
+ # a hair darker, breaking the ramp). Start c15 well above c7 with only a mild
512
+ # floor so it stays the lightest. (Terminals render the dark palette; this
513
+ # ramp only feeds non-terminal light-palette consumers.)
458
514
  return (
459
515
  ensure_contrast(lab_to_rgb(18.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 7.0, False),
460
- ensure_contrast(lab_to_rgb(bg_lab[0] - 8, bg_lab[1], bg_lab[2]), bg_rgb, 1.3, False),
516
+ ensure_contrast(lab_to_rgb(50.0, bg_lab[1] * 0.15, bg_lab[2] * 0.15), bg_rgb, 4.5, False),
461
517
  ensure_contrast(lab_to_rgb(35.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 4.5, False),
462
- ensure_contrast(lab_to_rgb(8.0, 0, 0), bg_rgb, 10.0, False),
518
+ ensure_contrast(lab_to_rgb(64.0, bg_lab[1] * 0.08, bg_lab[2] * 0.08), bg_rgb, 2.5, False),
463
519
  )
464
520
 
465
521
 
@@ -664,7 +720,15 @@ def main():
664
720
  # Generate theme
665
721
  theme = generate_theme(clusters, name, is_dark)
666
722
  wp_base = args.image.split("[")[0] if "[" in args.image else args.image
667
- theme["wallpaper"] = os.path.abspath(wp_base)
723
+ wp_abs = os.path.abspath(wp_base)
724
+ # Store home-relative with a `~` so the committed themes.toml is not tied to
725
+ # one machine's username. Consumers (wallpaper-sync.sh) expand `~/`. System
726
+ # wallpapers (/System/..., /usr/share/...) stay absolute — identical on
727
+ # every host anyway.
728
+ home = os.path.expanduser("~")
729
+ if wp_abs == home or wp_abs.startswith(home + os.sep):
730
+ wp_abs = "~" + wp_abs[len(home):]
731
+ theme["wallpaper"] = wp_abs
668
732
  theme["source"] = args.source
669
733
 
670
734
  # Output
@@ -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."