@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
@@ -15,12 +15,38 @@ source "$SCRIPT_DIR/../lib/dot/ui.sh"
15
15
  source "$SCRIPT_DIR/../lib/dot/log.sh"
16
16
  export DOT_COMMAND="bundle"
17
17
 
18
+ usage() {
19
+ cat <<'EOF'
20
+ Usage: bundle.sh [output-dir]
21
+
22
+ Create an offline dotfiles bundle as a .tar.zst archive.
23
+ EOF
24
+ }
25
+
26
+ case "${1:-}" in
27
+ --help | -h)
28
+ usage
29
+ exit 0
30
+ ;;
31
+ -*)
32
+ printf 'Unknown option: %s\n' "$1" >&2
33
+ usage >&2
34
+ exit 2
35
+ ;;
36
+ esac
37
+
18
38
  # Concurrency guard
19
- LOCK_FILE="${XDG_RUNTIME_DIR:-/tmp}/dotfiles-bundle.lock"
20
- exec 9>"$LOCK_FILE"
21
- if ! flock -n 9; then
22
- ui_warn "Already running" "Another bundle instance is active"
23
- exit 0
39
+ LOCK_DIR="${XDG_RUNTIME_DIR:-${TMPDIR:-/tmp}}"
40
+ if [[ ! -d "$LOCK_DIR" || ! -w "$LOCK_DIR" ]]; then
41
+ LOCK_DIR="${TMPDIR:-/tmp}"
42
+ fi
43
+ LOCK_FILE="$LOCK_DIR/dotfiles-bundle.lock"
44
+ if command -v flock >/dev/null 2>&1; then
45
+ exec 9>"$LOCK_FILE"
46
+ if ! flock -n 9; then
47
+ ui_warn "Already running" "Another bundle instance is active"
48
+ exit 0
49
+ fi
24
50
  fi
25
51
 
26
52
  OUTPUT_DIR="${1:-$HOME/Downloads}"
@@ -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
@@ -3,6 +3,10 @@
3
3
  # Copyright (c) 2015-2026 Sebastien Rousseau
4
4
  # shellcheck disable=SC2034
5
5
  # =============================================================================
6
+
7
+ HEAL_TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
+ # shellcheck source=../../lib/dot/verified-download.sh disable=SC1091
9
+ source "$HEAL_TOOLS_DIR/../../lib/dot/verified-download.sh"
6
10
  # heal-tools.sh — Tool installation helpers for heal.sh
7
11
  # Sourced by heal.sh; inherits set -euo pipefail, ui.sh, and shared variables.
8
12
  # =============================================================================
@@ -123,33 +127,56 @@ get_package_name() {
123
127
  # Usage: _pkg_install "label" completed total command [args...]
124
128
  _pkg_install() { ui_run_cmd "$@"; }
125
129
 
126
- # Resolve latest GitHub release tag for a repo
127
- _gh_latest_tag() {
128
- curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$1/releases/latest" | sed 's|.*/||'
130
+ _mise_tool_specs() {
131
+ case "$1" in
132
+ nushell) printf '%s\n' 'aqua:nushell/nushell@0.114.1' ;;
133
+ pueue)
134
+ printf '%s\n' 'aqua:Nukesor/pueue/pueue@4.0.4'
135
+ printf '%s\n' 'aqua:Nukesor/pueue/pueued@4.0.4'
136
+ ;;
137
+ wasmtime) printf '%s\n' 'wasmtime@47.0.3' ;;
138
+ sops) printf '%s\n' 'sops@3.13.3' ;;
139
+ yazi) printf '%s\n' 'yazi@26.5.6' ;;
140
+ zellij) printf '%s\n' 'zellij@0.44.3' ;;
141
+ *) return 1 ;;
142
+ esac
143
+ }
144
+
145
+ _install_with_mise() {
146
+ local cmd="$1" spec
147
+ command -v mise >/dev/null 2>&1 || return 1
148
+ while IFS= read -r spec; do
149
+ [[ -n "$spec" ]] || continue
150
+ # --pin writes an exact version to the user's writable global config;
151
+ # aqua/ubi backends verify publisher-provided checksums before install.
152
+ mise use --global --pin "$spec" || return 1
153
+ done < <(_mise_tool_specs "$cmd")
129
154
  }
130
155
 
131
156
  # Install a single package (dispatcher for _pkg_install to call in subshell)
132
157
  _do_install() {
133
158
  local cmd="$1"
134
159
  local pkg_mgr="$2"
135
- local bin_dir="$HOME/.local/bin"
136
- mkdir -p "$bin_dir"
160
+
161
+ case "$cmd" in
162
+ nushell | pueue | wasmtime | sops | yazi | zellij)
163
+ if _install_with_mise "$cmd"; then
164
+ return 0
165
+ fi
166
+ log_warn "mise install unavailable for $cmd; trying $pkg_mgr"
167
+ ;;
168
+ esac
137
169
 
138
170
  # Binary/curl installers for tools not in standard apt repos
139
171
  case "$cmd" in
140
172
  starship)
141
173
  local installer
142
174
  installer=$(umask 077 && mktemp)
143
- if ! curl -fsSL -o "$installer" https://starship.rs/install.sh; then
175
+ if ! download_verified_script https://starship.rs/install.sh "$installer"; then
144
176
  rm -f "$installer"
145
177
  log_error "Failed to download starship installer"
146
178
  return 1
147
179
  fi
148
- if ! head -1 "$installer" | grep -q '^#!/'; then
149
- rm -f "$installer"
150
- log_error "starship installer does not look like a shell script"
151
- return 1
152
- fi
153
180
  sh "$installer" --yes
154
181
  local rc=$?
155
182
  rm -f "$installer"
@@ -158,115 +185,16 @@ _do_install() {
158
185
  atuin)
159
186
  local installer
160
187
  installer=$(umask 077 && mktemp)
161
- if ! curl -fsSL -o "$installer" https://setup.atuin.sh; then
188
+ if ! download_verified_script https://setup.atuin.sh "$installer"; then
162
189
  rm -f "$installer"
163
190
  log_error "Failed to download atuin installer"
164
191
  return 1
165
192
  fi
166
- if ! head -1 "$installer" | grep -q '^#!/'; then
167
- rm -f "$installer"
168
- log_error "atuin installer does not look like a shell script"
169
- return 1
170
- fi
171
193
  bash "$installer" --yes
172
194
  local rc=$?
173
195
  rm -f "$installer"
174
196
  return $rc
175
197
  ;;
176
- nushell)
177
- local arch tag ver
178
- arch=$(uname -m)
179
- tag=$(_gh_latest_tag "nushell/nushell")
180
- ver="${tag#v}"
181
- local tmp
182
- tmp=$(mktemp -d)
183
- curl -fsSL -o "$tmp/nu.tar.gz" \
184
- "https://github.com/nushell/nushell/releases/download/${tag}/nu-${ver}-${arch}-unknown-linux-musl.tar.gz" &&
185
- tar xzf "$tmp/nu.tar.gz" -C "$tmp" --strip-components=1 &&
186
- install -m 755 "$tmp/nu" "$bin_dir/nu"
187
- local rc=$?
188
- rm -rf "$tmp"
189
- return $rc
190
- ;;
191
- pueue)
192
- local arch
193
- arch=$(uname -m)
194
- curl -fsSL -o "$bin_dir/pueue" \
195
- "https://github.com/Nukesor/pueue/releases/latest/download/pueue-${arch}-unknown-linux-musl" &&
196
- chmod +x "$bin_dir/pueue"
197
- curl -fsSL -o "$bin_dir/pueued" \
198
- "https://github.com/Nukesor/pueue/releases/latest/download/pueued-${arch}-unknown-linux-musl" &&
199
- chmod +x "$bin_dir/pueued"
200
- return $?
201
- ;;
202
- wasmtime)
203
- local arch tag
204
- arch=$(uname -m)
205
- tag=$(_gh_latest_tag "bytecodealliance/wasmtime")
206
- local tmp
207
- tmp=$(mktemp -d)
208
- # xz-utils required for .tar.xz extraction
209
- command -v xz >/dev/null 2>&1 || sudo apt-get install -y -qq xz-utils >/dev/null 2>&1 || true
210
- curl -fsSL -o "$tmp/wasmtime.tar.xz" \
211
- "https://github.com/bytecodealliance/wasmtime/releases/download/${tag}/wasmtime-${tag}-${arch}-linux.tar.xz" &&
212
- tar xJf "$tmp/wasmtime.tar.xz" -C "$tmp" --strip-components=1 &&
213
- install -m 755 "$tmp/wasmtime" "$bin_dir/wasmtime"
214
- local rc=$?
215
- rm -rf "$tmp"
216
- return $rc
217
- ;;
218
- sops)
219
- local arch tag
220
- arch=$(uname -m)
221
- [[ "$arch" == "x86_64" ]] && arch="amd64"
222
- [[ "$arch" == "aarch64" ]] && arch="arm64"
223
- tag=$(_gh_latest_tag "getsops/sops")
224
- curl -fsSL -o "$bin_dir/sops" \
225
- "https://github.com/getsops/sops/releases/download/${tag}/sops-${tag}.linux.${arch}" &&
226
- chmod +x "$bin_dir/sops"
227
- return $?
228
- ;;
229
- hyperfine)
230
- local arch tag
231
- arch=$(uname -m)
232
- tag=$(_gh_latest_tag "sharkdp/hyperfine")
233
- local tmp
234
- tmp=$(mktemp -d)
235
- curl -fsSL -o "$tmp/hyperfine.tar.gz" \
236
- "https://github.com/sharkdp/hyperfine/releases/download/${tag}/hyperfine-${tag}-${arch}-unknown-linux-musl.tar.gz" &&
237
- tar xzf "$tmp/hyperfine.tar.gz" -C "$tmp" --strip-components=1 &&
238
- install -m 755 "$tmp/hyperfine" "$bin_dir/hyperfine"
239
- local rc=$?
240
- rm -rf "$tmp"
241
- return $rc
242
- ;;
243
- yazi)
244
- local arch
245
- arch=$(uname -m)
246
- local url="https://github.com/sxyazi/yazi/releases/latest/download/yazi-${arch}-unknown-linux-musl.zip"
247
- local tmp
248
- tmp=$(mktemp -d)
249
- command -v unzip >/dev/null 2>&1 || sudo apt-get install -y -qq unzip
250
- curl -fsSL -o "$tmp/yazi.zip" "$url" &&
251
- (cd "$tmp" && unzip -oq yazi.zip) &&
252
- install -m 755 "$tmp"/yazi-*/yazi "$bin_dir/yazi"
253
- local rc=$?
254
- rm -rf "$tmp"
255
- return $rc
256
- ;;
257
- zellij)
258
- local arch
259
- arch=$(uname -m)
260
- local url="https://github.com/zellij-org/zellij/releases/latest/download/zellij-${arch}-unknown-linux-musl.tar.gz"
261
- local tmp
262
- tmp=$(mktemp -d)
263
- curl -fsSL -o "$tmp/zellij.tar.gz" "$url" &&
264
- tar xzf "$tmp/zellij.tar.gz" -C "$tmp" &&
265
- install -m 755 "$tmp/zellij" "$bin_dir/zellij"
266
- local rc=$?
267
- rm -rf "$tmp"
268
- return $rc
269
- ;;
270
198
  esac
271
199
 
272
200
  # System package manager
File without changes
@@ -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
+ ./tests/framework/test_runner.sh --jobs auto --integration-only
74
74
  }
75
75
 
76
76
  coverage_gate() {
@@ -123,11 +123,11 @@ fi
123
123
 
124
124
  printf 'Reliability Audit\n'
125
125
  printf 'Platform: %s\n' "$(platform_name)"
126
- printf 'Coverage floor: %s%%\n' "$min_coverage"
126
+ printf 'Executable module-mapping floor: %s%%\n' "$min_coverage"
127
127
 
128
128
  run_step "Shell syntax" shell_syntax
129
129
  run_step "Unit suite" unit_tests
130
- run_step "Module coverage" coverage_gate
130
+ run_step "Executable module mapping" coverage_gate
131
131
  run_step "Docs coverage" docs_coverage_gate
132
132
  run_step "Traceability coverage" traceability_gate
133
133
  run_step "Executable examples" example_gate
@@ -20,7 +20,7 @@
20
20
 
21
21
  set -euo pipefail
22
22
 
23
- REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
23
+ REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
24
24
  cd "$REPO_ROOT"
25
25
 
26
26
  mode="write"
@@ -51,7 +51,7 @@ if ! command -v jq >/dev/null 2>&1; then
51
51
  exit 2
52
52
  fi
53
53
 
54
- payload="$(curl -sSfL "$api")" || {
54
+ payload="$(curl --proto '=https' --tlsv1.2 -fsSL --connect-timeout 10 --max-time 30 "$api")" || {
55
55
  echo "Scorecard API unreachable: $api" >&2
56
56
  exit 2
57
57
  }
@@ -75,7 +75,7 @@ trap 'rm -f "$block"' EXIT
75
75
  | [.name, (.score|tostring), ((.reason // "") | gsub("\\|"; "\\|") | .[0:100])]
76
76
  | "| " + (. | join(" | ")) + " |"'
77
77
  # shellcheck disable=SC2016 # backticks here are markdown code-spans, not subshells
78
- printf '\n_Refresh: `scripts/qa/scorecard-snapshot.sh` · CI check: `lint/scorecard-snapshot` (planned)._\n'
78
+ printf '\n_Refresh: `scripts/qa/scorecard-snapshot.sh` · Monitoring: `.github/workflows/scorecard.yml`._\n'
79
79
  printf '<!-- END scorecard-snapshot -->\n'
80
80
  } >"$block"
81
81
 
File without changes
File without changes
@@ -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