@sebastienrousseau/dotfiles 0.2.520 → 0.2.521

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 (323) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +43 -37
  5. package/install.sh +76 -10
  6. package/package.json +7 -7
  7. package/tools/README.md +49 -0
  8. package/tools/ci/install-chezmoi-verified.sh +68 -0
  9. package/docs/.vitepress/reports/localization-readability-audit.md +0 -73
  10. package/docs/AI.md +0 -179
  11. package/docs/ARCHITECTURE.md +0 -117
  12. package/docs/CNAME +0 -1
  13. package/docs/CONFIG_STRATEGY.md +0 -124
  14. package/docs/COPYRIGHT +0 -7
  15. package/docs/ECOSYSTEM.md +0 -220
  16. package/docs/GOLD-STANDARD-AUDIT.md +0 -352
  17. package/docs/GOVERNANCE.md +0 -98
  18. package/docs/MAINTAINERS.md +0 -41
  19. package/docs/MINIMUM-TOOLCHAIN.md +0 -100
  20. package/docs/NAMING_CONVENTIONS.md +0 -102
  21. package/docs/OPENCODE.md +0 -127
  22. package/docs/README.md +0 -84
  23. package/docs/STRUCTURE.md +0 -102
  24. package/docs/adr/ADR-001-ci-cd-pipeline.md +0 -118
  25. package/docs/adr/ADR-002-shell-performance.md +0 -130
  26. package/docs/adr/ADR-003-security-first.md +0 -158
  27. package/docs/adr/ADR-004-cli-architecture.md +0 -171
  28. package/docs/adr/ADR-005-chezmoi-choice.md +0 -99
  29. package/docs/adr/ADR-006-shell-selection.md +0 -124
  30. package/docs/adr/ADR-007-multi-shell-parity.md +0 -62
  31. package/docs/adr/ADR-008-alias-system-architecture.md +0 -95
  32. package/docs/adr/ADR-009-wallpaper-driven-theming.md +0 -131
  33. package/docs/adr/ADR-010-starship-transient-prompt.md +0 -144
  34. package/docs/adr/ADR-011-nushell-tier3-keep.md +0 -144
  35. package/docs/adr/ADR-012-ai-fleet-local-proxy.md +0 -79
  36. package/docs/adr/README.md +0 -40
  37. package/docs/architecture/AI_COST_OPTIMIZATION.md +0 -144
  38. package/docs/architecture/ARCHITECTURE.md +0 -20
  39. package/docs/architecture/INTEROP.md +0 -44
  40. package/docs/architecture/REPO_LAYOUT.md +0 -241
  41. package/docs/architecture/WALKTHROUGH.md +0 -86
  42. package/docs/architecture/fleet-deployment.md +0 -77
  43. package/docs/archive/EUXIS_2026_REVIEW.md +0 -127
  44. package/docs/archive/LEGACY_ROADMAP.md +0 -6
  45. package/docs/archive/MILESTONE_v0.2.493.md +0 -47
  46. package/docs/archive/PLAN.md +0 -199
  47. package/docs/archive/REPO_AUDIT.md +0 -31
  48. package/docs/articles/.pages +0 -6
  49. package/docs/articles/2026-07-05-custom-mkdocs-material-dark-theme.md +0 -216
  50. package/docs/articles/2026-07-05-fish-startup-abbr.md +0 -153
  51. package/docs/articles/2026-07-05-master-to-main-rename-runbook.md +0 -128
  52. package/docs/articles/index.md +0 -36
  53. package/docs/guides/INSTALL.md +0 -144
  54. package/docs/guides/MACOS_ICLOUD_SYMLINKS.md +0 -121
  55. package/docs/guides/NEOVIM_IDE_GUIDE.md +0 -61
  56. package/docs/guides/THEMING.md +0 -230
  57. package/docs/guides/TROUBLESHOOTING.md +0 -176
  58. package/docs/guides/WSL2_NIX_TROUBLESHOOTING.md +0 -792
  59. package/docs/index.md +0 -132
  60. package/docs/interop/A2A.md +0 -39
  61. package/docs/interop/POWERSHELL.md +0 -102
  62. package/docs/manual/00-introduction.md +0 -89
  63. package/docs/manual/01-concepts/01-architecture.md +0 -138
  64. package/docs/manual/01-concepts/02-trust-model.md +0 -183
  65. package/docs/manual/01-concepts/03-theme-engine.md +0 -186
  66. package/docs/manual/01-concepts/04-fleet.md +0 -148
  67. package/docs/manual/01-concepts/05-self-healing.md +0 -204
  68. package/docs/manual/02-tutorials/01-first-install.md +0 -197
  69. package/docs/manual/02-tutorials/02-add-wallpaper.md +0 -216
  70. package/docs/manual/02-tutorials/03-create-profile.md +0 -244
  71. package/docs/manual/02-tutorials/04-encrypt-secret.md +0 -281
  72. package/docs/manual/02-tutorials/05-deploy-fleet.md +0 -283
  73. package/docs/manual/03-reference/01-dot-cli.md +0 -475
  74. package/docs/manual/03-reference/02-config-files.md +0 -265
  75. package/docs/manual/03-reference/03-environment.md +0 -124
  76. package/docs/manual/03-reference/04-templates.md +0 -190
  77. package/docs/manual/03-reference/05-feature-flags.md +0 -187
  78. package/docs/manual/04-cookbook/01-recipes.md +0 -285
  79. package/docs/manual/04-cookbook/02-troubleshooting.md +0 -351
  80. package/docs/manual/04-cookbook/03-faq.md +0 -175
  81. package/docs/manual/05-appendices/A-platform-matrix.md +0 -101
  82. package/docs/manual/05-appendices/B-security-checklist.md +0 -85
  83. package/docs/manual/05-appendices/C-glossary.md +0 -40
  84. package/docs/manual/05-appendices/D-bibliography.md +0 -58
  85. package/docs/manual/05-appendices/E-license.md +0 -38
  86. package/docs/manual/_toc.yml +0 -58
  87. package/docs/manual/command-index.md +0 -175
  88. package/docs/manual/concept-index.md +0 -170
  89. package/docs/manual/index.md +0 -66
  90. package/docs/migration/README.md +0 -81
  91. package/docs/migration/from-bare-git-repo.md +0 -156
  92. package/docs/migration/from-gnu-stow.md +0 -165
  93. package/docs/migration/from-plain-chezmoi.md +0 -148
  94. package/docs/migration/from-yadm.md +0 -187
  95. package/docs/operations/ARCHITECTURE_ROADMAP.md +0 -7
  96. package/docs/operations/ATTESTATION.md +0 -44
  97. package/docs/operations/CI_CADENCE.md +0 -107
  98. package/docs/operations/CI_COMPOSITES.md +0 -156
  99. package/docs/operations/COMPLETIONS.md +0 -123
  100. package/docs/operations/COVERAGE.md +0 -204
  101. package/docs/operations/DRIFT.md +0 -107
  102. package/docs/operations/HARD_AUDIT_2026.md +0 -631
  103. package/docs/operations/MAINTENANCE.md +0 -63
  104. package/docs/operations/MANIFEST.md +0 -127
  105. package/docs/operations/MIGRATION.md +0 -109
  106. package/docs/operations/OPERATIONS.md +0 -188
  107. package/docs/operations/PERFORMANCE.md +0 -133
  108. package/docs/operations/PERFORMANCE_BUDGETS.md +0 -196
  109. package/docs/operations/REGISTRY.md +0 -90
  110. package/docs/operations/RELEASE_PIPELINE.md +0 -128
  111. package/docs/operations/RELIABILITY.md +0 -122
  112. package/docs/operations/RFC_v0_2_503_reorganization.md +0 -280
  113. package/docs/operations/ROADMAP.md +0 -10
  114. package/docs/operations/ROADMAP_2026.md +0 -7
  115. package/docs/operations/ROADMAP_V0_2_503.md +0 -10
  116. package/docs/operations/TESTING.md +0 -216
  117. package/docs/operations/TRACEABILITY.md +0 -44
  118. package/docs/operations/TRUSTED_AGENT_WORKSTATION.md +0 -65
  119. package/docs/operations/VERSION_SYNC.md +0 -393
  120. package/docs/packaging.md +0 -222
  121. package/docs/reference/ALIASES.md +0 -131
  122. package/docs/reference/ALIASES_CHEATSHEET.md +0 -32
  123. package/docs/reference/ALIASES_DEPRECATIONS.md +0 -13
  124. package/docs/reference/FEATURE-MATRIX.md +0 -646
  125. package/docs/reference/FEATURES.md +0 -66
  126. package/docs/reference/FONTS.md +0 -112
  127. package/docs/reference/POWERSHELL_PARITY.md +0 -82
  128. package/docs/reference/PROFILES.md +0 -69
  129. package/docs/reference/SCREENSHOTS.md +0 -121
  130. package/docs/reference/SCRIPTS.md +0 -71
  131. package/docs/reference/SUPPORT_MATRIX.md +0 -80
  132. package/docs/reference/THEMES.md +0 -117
  133. package/docs/reference/TOOLS.md +0 -110
  134. package/docs/reference/UTILS.md +0 -243
  135. package/docs/registry.json +0 -6
  136. package/docs/schema/dot-env-v1.json +0 -110
  137. package/docs/schema/dot-registry-v1.json +0 -33
  138. package/docs/security/AI_ACT_COMPLIANCE.md +0 -94
  139. package/docs/security/AUDIT_BYPASS.md +0 -103
  140. package/docs/security/AUTOMATION_SECRETS.md +0 -26
  141. package/docs/security/CI_EGRESS_ALLOWLIST.md +0 -127
  142. package/docs/security/CI_PINNING.md +0 -129
  143. package/docs/security/COMMIT_SIGNING.md +0 -138
  144. package/docs/security/COMPLIANCE.md +0 -458
  145. package/docs/security/DEPS_DEV_EXCEPTIONS.md +0 -86
  146. package/docs/security/DISCLOSURE.md +0 -130
  147. package/docs/security/ENCRYPTION.md +0 -57
  148. package/docs/security/FMEA.md +0 -159
  149. package/docs/security/FUZZING.md +0 -209
  150. package/docs/security/HISTORY_FILTERING.md +0 -132
  151. package/docs/security/INCIDENT_RESPONSE.md +0 -579
  152. package/docs/security/INSTALL_VERIFICATION.md +0 -122
  153. package/docs/security/KEYS.md +0 -49
  154. package/docs/security/KEY_ROTATION.md +0 -303
  155. package/docs/security/MCP_POLICY.md +0 -78
  156. package/docs/security/POLICY_RELEASES.md +0 -37
  157. package/docs/security/README.md +0 -28
  158. package/docs/security/SCORECARD.md +0 -195
  159. package/docs/security/SECRETS.md +0 -158
  160. package/docs/security/SECURITY.md +0 -45
  161. package/docs/security/SECURITY_CHECKLIST.md +0 -55
  162. package/docs/security/SHELL_EXEMPTIONS.md +0 -145
  163. package/docs/security/SOUP_REGISTER.md +0 -36
  164. package/docs/security/THREAT_MODEL.md +0 -130
  165. package/docs/security/VERIFICATION_VALIDATION.md +0 -228
  166. package/docs/security/VERIFY_RELEASE.md +0 -201
  167. package/docs/security/security-pubkey.asc +0 -15
  168. package/docs/stylesheets/extra.css +0 -444
  169. package/docs/themes/README.md +0 -10
  170. package/docs/themes/VISUAL_INTEGRITY_REPORT.md +0 -30
  171. package/docs/themes/hero-shot.svg +0 -78
  172. package/scripts/README.md +0 -123
  173. package/scripts/ci/check-copyright-headers.sh +0 -8
  174. package/scripts/ci/check-shell-preamble.sh +0 -8
  175. package/scripts/ci/guard-gitleaks-checkout.sh +0 -8
  176. package/scripts/demo/record.sh +0 -43
  177. package/scripts/diagnostics/a2a-conformance.sh +0 -163
  178. package/scripts/diagnostics/alias-governance.sh +0 -165
  179. package/scripts/diagnostics/aliases-cheatsheet.sh +0 -74
  180. package/scripts/diagnostics/aliases-manifest.sh +0 -77
  181. package/scripts/diagnostics/attest-verify.sh +0 -147
  182. package/scripts/diagnostics/benchmark.sh +0 -408
  183. package/scripts/diagnostics/conflicts.sh +0 -73
  184. package/scripts/diagnostics/doctor-unified.sh +0 -43
  185. package/scripts/diagnostics/doctor.sh +0 -797
  186. package/scripts/diagnostics/drift-dashboard.sh +0 -203
  187. package/scripts/diagnostics/health.sh +0 -656
  188. package/scripts/diagnostics/history-analysis.sh +0 -86
  189. package/scripts/diagnostics/mcp-doctor.sh +0 -582
  190. package/scripts/diagnostics/perf.sh +0 -453
  191. package/scripts/diagnostics/scorecard.sh +0 -120
  192. package/scripts/diagnostics/secret-governance.sh +0 -65
  193. package/scripts/diagnostics/security-score.sh +0 -467
  194. package/scripts/diagnostics/smoke-test.sh +0 -88
  195. package/scripts/diagnostics/snapshot.sh +0 -90
  196. package/scripts/diagnostics/verify.sh +0 -108
  197. package/scripts/diagnostics/verify_state.sh +0 -73
  198. package/scripts/diagnostics/version-locks.sh +0 -94
  199. package/scripts/diagnostics/workstation-attestation.sh +0 -212
  200. package/scripts/dot/commands/agent.sh +0 -535
  201. package/scripts/dot/commands/agents.sh +0 -352
  202. package/scripts/dot/commands/ai.sh +0 -600
  203. package/scripts/dot/commands/aliases.sh +0 -277
  204. package/scripts/dot/commands/appearance.sh +0 -110
  205. package/scripts/dot/commands/completion.sh +0 -171
  206. package/scripts/dot/commands/core.sh +0 -217
  207. package/scripts/dot/commands/diagnostics.sh +0 -265
  208. package/scripts/dot/commands/env-emit.sh +0 -203
  209. package/scripts/dot/commands/fleet.sh +0 -711
  210. package/scripts/dot/commands/init.sh +0 -185
  211. package/scripts/dot/commands/lint.sh +0 -208
  212. package/scripts/dot/commands/manual.sh +0 -169
  213. package/scripts/dot/commands/meta.sh +0 -438
  214. package/scripts/dot/commands/patterns.sh +0 -55
  215. package/scripts/dot/commands/registry.sh +0 -455
  216. package/scripts/dot/commands/restore.sh +0 -232
  217. package/scripts/dot/commands/secrets.sh +0 -296
  218. package/scripts/dot/commands/security.sh +0 -102
  219. package/scripts/dot/commands/tools.sh +0 -570
  220. package/scripts/dot/data/alias-deprecations.tsv +0 -2
  221. package/scripts/dot/powershell/Dot.psm1 +0 -319
  222. package/scripts/fonts/install-nerd-fonts.sh +0 -75
  223. package/scripts/fonts/patch-fonts.sh +0 -36
  224. package/scripts/git-hooks/install.sh +0 -12
  225. package/scripts/git-hooks/pre-commit +0 -12
  226. package/scripts/git-hooks/pre-commit-audit.sh +0 -146
  227. package/scripts/git-hooks/pre-push +0 -105
  228. package/scripts/git-hooks/prepare-commit-msg +0 -29
  229. package/scripts/lib/secrets_provider.sh +0 -200
  230. package/scripts/nvim/headless-upgrade.lua +0 -81
  231. package/scripts/ops/ai-setup.sh +0 -71
  232. package/scripts/ops/bundle.sh +0 -104
  233. package/scripts/ops/chaos.sh +0 -50
  234. package/scripts/ops/chezmoi-apply.sh +0 -333
  235. package/scripts/ops/chezmoi-diff.sh +0 -16
  236. package/scripts/ops/chezmoi-remove.sh +0 -46
  237. package/scripts/ops/chezmoi-update.sh +0 -67
  238. package/scripts/ops/heal-chezmoi.sh +0 -87
  239. package/scripts/ops/heal-system.sh +0 -129
  240. package/scripts/ops/heal-tools.sh +0 -297
  241. package/scripts/ops/heal.sh +0 -223
  242. package/scripts/ops/post-apply-repair.sh +0 -107
  243. package/scripts/ops/prewarm.sh +0 -128
  244. package/scripts/ops/release.sh +0 -262
  245. package/scripts/ops/rollback.sh +0 -613
  246. package/scripts/ops/setup.sh +0 -138
  247. package/scripts/ops/teleport.sh +0 -34
  248. package/scripts/qa/check-feature-matrix.sh +0 -296
  249. package/scripts/qa/check-version-consistency.sh +0 -12
  250. package/scripts/qa/coverage-baseline.sh +0 -61
  251. package/scripts/qa/docs-coverage.sh +0 -118
  252. package/scripts/qa/examples-coverage.sh +0 -94
  253. package/scripts/qa/powershell-contract.ps1 +0 -95
  254. package/scripts/qa/reliability-audit.sh +0 -139
  255. package/scripts/qa/scorecard-snapshot.sh +0 -128
  256. package/scripts/qa/traceability-coverage.sh +0 -124
  257. package/scripts/qa/validate-examples.sh +0 -90
  258. package/scripts/qa/wsl-contract.sh +0 -12
  259. package/scripts/secrets/age-init.sh +0 -82
  260. package/scripts/secrets/create-secrets-file.sh +0 -46
  261. package/scripts/secrets/encrypt-ssh-key.sh +0 -44
  262. package/scripts/security/backup.sh +0 -58
  263. package/scripts/security/check-disclosure-key-expiry.sh +0 -111
  264. package/scripts/security/dns-doh.sh +0 -52
  265. package/scripts/security/encryption-check.sh +0 -55
  266. package/scripts/security/enforce-policies.sh +0 -552
  267. package/scripts/security/firewall.sh +0 -91
  268. package/scripts/security/lock-configs.sh +0 -67
  269. package/scripts/security/lock-screen.sh +0 -56
  270. package/scripts/security/manage-secrets.sh +0 -429
  271. package/scripts/security/ssh-cert.sh +0 -204
  272. package/scripts/security/telemetry-kill.sh +0 -51
  273. package/scripts/security/usb-safety.sh +0 -52
  274. package/scripts/theme/apply-gnome-theme.sh +0 -333
  275. package/scripts/theme/extract-heic-frames.sh +0 -115
  276. package/scripts/theme/extract-theme.py +0 -1020
  277. package/scripts/theme/install-boot-logo.sh +0 -63
  278. package/scripts/theme/install-catppuccin-themes.sh +0 -371
  279. package/scripts/theme/install-cursors.sh +0 -26
  280. package/scripts/theme/install-file-icons.sh +0 -27
  281. package/scripts/theme/install-grub-theme.sh +0 -62
  282. package/scripts/theme/install-lock-icon.sh +0 -31
  283. package/scripts/theme/merge-wallpaper.sh +0 -146
  284. package/scripts/theme/rebuild-themes.sh +0 -603
  285. package/scripts/theme/switch.sh +0 -476
  286. package/scripts/theme/wallpaper-rotate.sh +0 -137
  287. package/scripts/theme/wallpaper-sync.sh +0 -690
  288. package/scripts/tools/cmatrix.sh +0 -22
  289. package/scripts/tools/detect-collisions.py +0 -103
  290. package/scripts/tools/emoji-picker.sh +0 -49
  291. package/scripts/tools/figlet-banner.sh +0 -19
  292. package/scripts/tools/log-rotate.sh +0 -31
  293. package/scripts/tools/lolcat-wrap.sh +0 -20
  294. package/scripts/tools/pipes.sh +0 -49
  295. package/scripts/tuning/linux.sh +0 -186
  296. package/scripts/tuning/macos.sh +0 -56
  297. package/scripts/uninstall.sh +0 -86
  298. package/scripts/verify-release-versions +0 -156
  299. package/scripts/version-sync.sh +0 -714
  300. package/templates/chezmoi-data/geekom-a9.toml.example +0 -21
  301. package/templates/chezmoi-data/mac-m1.toml.example +0 -16
  302. package/templates/chezmoi-data/mac-t2-linux.toml.example +0 -21
  303. package/templates/chezmoi-data/surface-pro-7p.toml.example +0 -21
  304. package/templates/projects/go/.github/workflows/ci.yml +0 -31
  305. package/templates/projects/go/README.md +0 -7
  306. package/templates/projects/go/cmd/__PROJECT_NAME__/main.go +0 -8
  307. package/templates/projects/go/go.mod +0 -3
  308. package/templates/projects/go/go.sum +0 -0
  309. package/templates/projects/molecule/README.md +0 -7
  310. package/templates/projects/molecule/converge.yml +0 -7
  311. package/templates/projects/molecule/molecule.yml +0 -16
  312. package/templates/projects/node/.github/workflows/ci.yml +0 -30
  313. package/templates/projects/node/README.md +0 -7
  314. package/templates/projects/node/package-lock.json +0 -12
  315. package/templates/projects/node/package.json +0 -10
  316. package/templates/projects/node/src/index.js +0 -3
  317. package/templates/projects/packer/README.md +0 -15
  318. package/templates/projects/packer/main.pkr.hcl +0 -15
  319. package/templates/projects/python/.github/workflows/ci.yml +0 -34
  320. package/templates/projects/python/README.md +0 -7
  321. package/templates/projects/python/pyproject.toml +0 -25
  322. package/templates/projects/python/src/__PROJECT_NAME__/__init__.py +0 -2
  323. package/templates/projects/python/tests/test_basic.py +0 -3
@@ -1,1020 +0,0 @@
1
- #!/usr/bin/env python3
2
- # SPDX-License-Identifier: Apache-2.0 OR MIT
3
- # Copyright (c) 2015-2026 Sebastien Rousseau
4
- # extract-theme.py — Generate a terminal theme from wallpaper dominant colors.
5
- #
6
- # Uses K-Means clustering in CIELAB color space for perceptually accurate
7
- # dominant color extraction. No external dependencies — stdlib only.
8
- # ImageMagick is used for image downsampling and pixel extraction.
9
- #
10
- # Usage:
11
- # python3 extract-theme.py <image-path> [--name <theme-name>] [--format toml|json]
12
- # python3 extract-theme.py /System/Library/Desktop\ Pictures/Sonoma.heic
13
- # python3 extract-theme.py ~/Pictures/Wallpapers/macos-tahoe-dark.heic --name macos-tahoe-dark
14
-
15
- import sys
16
- import subprocess # nosec B404 — used only with fixed magick command, no shell
17
- import math
18
- import random # nosec B311 — used for K-Means seeding, not security
19
- import json
20
- import os
21
- from typing import List, Tuple, Dict
22
-
23
- # ---------------------------------------------------------------------------
24
- # Color space conversions (RGB ↔ XYZ ↔ CIELAB)
25
- # ---------------------------------------------------------------------------
26
-
27
- def srgb_to_linear(c: float) -> float:
28
- """Linearize an sRGB component (0-1)."""
29
- return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
30
-
31
-
32
- def linear_to_srgb(c: float) -> float:
33
- """Gamma-compress a linear component to sRGB (0-1)."""
34
- c = max(0.0, min(1.0, c))
35
- return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1.0 / 2.4)) - 0.055
36
-
37
-
38
- def rgb_to_xyz(r: int, g: int, b: int) -> Tuple[float, float, float]:
39
- """Convert sRGB (0-255) to CIE XYZ (D65 illuminant)."""
40
- rl = srgb_to_linear(r / 255.0)
41
- gl = srgb_to_linear(g / 255.0)
42
- bl = srgb_to_linear(b / 255.0)
43
- x = 0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl
44
- y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl
45
- z = 0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl
46
- return x, y, z
47
-
48
-
49
- def xyz_to_lab(x: float, y: float, z: float) -> Tuple[float, float, float]:
50
- """Convert CIE XYZ to CIELAB (D65 reference white)."""
51
- xn, yn, zn = 0.95047, 1.00000, 1.08883
52
-
53
- def f(t):
54
- return t ** (1.0 / 3.0) if t > 0.008856 else 7.787 * t + 16.0 / 116.0
55
-
56
- fx, fy, fz = f(x / xn), f(y / yn), f(z / zn)
57
- L = 116.0 * fy - 16.0
58
- a = 500.0 * (fx - fy)
59
- b = 200.0 * (fy - fz)
60
- return L, a, b
61
-
62
-
63
- def lab_to_xyz(L: float, a: float, b: float) -> Tuple[float, float, float]:
64
- """Convert CIELAB to CIE XYZ (D65 reference white)."""
65
- xn, yn, zn = 0.95047, 1.00000, 1.08883
66
- fy = (L + 16.0) / 116.0
67
- fx = a / 500.0 + fy
68
- fz = fy - b / 200.0
69
-
70
- def inv_f(t):
71
- return t ** 3 if t ** 3 > 0.008856 else (t - 16.0 / 116.0) / 7.787
72
-
73
- return inv_f(fx) * xn, inv_f(fy) * yn, inv_f(fz) * zn
74
-
75
-
76
- def xyz_to_rgb(x: float, y: float, z: float) -> Tuple[int, int, int]:
77
- """Convert CIE XYZ to sRGB (0-255)."""
78
- rl = 3.2404542 * x - 1.5371385 * y - 0.4985314 * z
79
- gl = -0.9692660 * x + 1.8760108 * y + 0.0415561 * z
80
- bl = 0.0556434 * x - 0.2040259 * y + 1.0572252 * z
81
- r = int(round(linear_to_srgb(rl) * 255))
82
- g = int(round(linear_to_srgb(gl) * 255))
83
- b = int(round(linear_to_srgb(bl) * 255))
84
- return max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b))
85
-
86
-
87
- def rgb_to_lab(r: int, g: int, b: int) -> Tuple[float, float, float]:
88
- return xyz_to_lab(*rgb_to_xyz(r, g, b))
89
-
90
-
91
- def lab_to_rgb(L: float, a: float, b: float) -> Tuple[int, int, int]:
92
- return xyz_to_rgb(*lab_to_xyz(L, a, b))
93
-
94
-
95
- def rgb_to_hex(r: int, g: int, b: int) -> str:
96
- return f"#{r:02x}{g:02x}{b:02x}"
97
-
98
-
99
- def hex_to_rgb(h: str) -> Tuple[int, int, int]:
100
- h = h.lstrip("#")
101
- return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
102
-
103
-
104
- # ---------------------------------------------------------------------------
105
- # Perceptual utilities
106
- # ---------------------------------------------------------------------------
107
-
108
- def lab_distance(a: Tuple[float, ...], b: Tuple[float, ...]) -> float:
109
- """Euclidean distance in CIELAB (ΔE*ab)."""
110
- return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
111
-
112
-
113
- def relative_luminance(r: int, g: int, b: int) -> float:
114
- """WCAG relative luminance from sRGB."""
115
- def ch(c):
116
- s = c / 255.0
117
- return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
118
- return 0.2126 * ch(r) + 0.7152 * ch(g) + 0.0722 * ch(b)
119
-
120
-
121
- def contrast_ratio(c1: Tuple[int, int, int], c2: Tuple[int, int, int]) -> float:
122
- l1 = relative_luminance(*c1)
123
- l2 = relative_luminance(*c2)
124
- return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
125
-
126
-
127
- def lab_chroma(L: float, a: float, b: float) -> float:
128
- """Chroma (saturation) in CIELAB."""
129
- return math.sqrt(a * a + b * b)
130
-
131
-
132
- def lab_hue(L: float, a: float, b: float) -> float:
133
- """Hue angle in CIELAB (0-360)."""
134
- h = math.degrees(math.atan2(b, a))
135
- return h if h >= 0 else h + 360.0
136
-
137
-
138
- # ---------------------------------------------------------------------------
139
- # K-Means clustering in CIELAB
140
- # ---------------------------------------------------------------------------
141
-
142
- def _kmeans_init(pixels, k, rng):
143
- """Initialize k centroids using K-Means++ seeding."""
144
- n = len(pixels)
145
- centroids = [pixels[rng.randint(0, n - 1)]]
146
- for _ in range(1, k):
147
- dists = [min(lab_distance(p, c) ** 2 for c in centroids) for p in pixels]
148
- total = sum(dists)
149
- if total == 0:
150
- centroids.append(pixels[rng.randint(0, n - 1)])
151
- continue
152
- r = rng.random() * total
153
- cumulative = 0.0
154
- for i, d in enumerate(dists):
155
- cumulative += d
156
- if cumulative >= r:
157
- centroids.append(pixels[i])
158
- break
159
- return centroids
160
-
161
-
162
- def _assign_labels(pixels, centroids, labels):
163
- """Assign each pixel to its nearest centroid. Returns number of changed labels."""
164
- changed = 0
165
- for i, p in enumerate(pixels):
166
- best_j = 0
167
- best_d = lab_distance(p, centroids[0])
168
- for j in range(1, len(centroids)):
169
- d = lab_distance(p, centroids[j])
170
- if d < best_d:
171
- best_d = d
172
- best_j = j
173
- if labels[i] != best_j:
174
- changed += 1
175
- labels[i] = best_j
176
- return changed
177
-
178
-
179
- def _update_centroids(pixels, labels, centroids):
180
- """Recompute centroids as the mean of assigned pixels."""
181
- n = len(pixels)
182
- for j, _ in enumerate(centroids):
183
- members = [pixels[i] for i in range(n) if labels[i] == j]
184
- if members:
185
- centroids[j] = tuple(
186
- sum(m[d] for m in members) / len(members) for d in range(3)
187
- )
188
-
189
-
190
- def _kmeans_single_run(pixels, k, max_iter, rng):
191
- """Run one K-Means iteration loop. Returns (centroids, labels, inertia)."""
192
- centroids = _kmeans_init(pixels, k, rng)
193
- labels = [0] * len(pixels)
194
- for _ in range(max_iter):
195
- changed = _assign_labels(pixels, centroids, labels)
196
- if changed == 0:
197
- break
198
- _update_centroids(pixels, labels, centroids)
199
- inertia = sum(
200
- lab_distance(pixels[i], centroids[labels[i]]) ** 2 for i in range(len(pixels))
201
- )
202
- return centroids, labels, inertia
203
-
204
-
205
- def kmeans_lab(
206
- pixels: List[Tuple[float, float, float]],
207
- k: int = 8,
208
- max_iter: int = 20,
209
- runs: int = 3,
210
- ) -> List[Tuple[Tuple[float, float, float], int]]:
211
- """Run K-Means clustering in CIELAB space with best-of-N initialization."""
212
- n = len(pixels)
213
- if n == 0:
214
- return []
215
-
216
- best_centroids = None
217
- best_labels = None
218
- best_inertia = float("inf")
219
-
220
- for run_idx in range(runs):
221
- rng = random.Random(run_idx * 42 + 7) # nosec B311
222
- centroids, labels, inertia = _kmeans_single_run(pixels, k, max_iter, rng)
223
- if inertia < best_inertia:
224
- best_inertia = inertia
225
- best_centroids = centroids[:]
226
- best_labels = labels[:]
227
-
228
- populations = [0] * k
229
- for label in best_labels:
230
- populations[label] += 1
231
-
232
- result = [(best_centroids[j], populations[j]) for j in range(k)]
233
- result.sort(key=lambda x: x[1], reverse=True)
234
- return result
235
-
236
-
237
- # ---------------------------------------------------------------------------
238
- # Pixel extraction via ImageMagick
239
- # ---------------------------------------------------------------------------
240
-
241
- def extract_pixels(image_path: str, max_dim: int = 80) -> List[Tuple[int, int, int]]:
242
- """Downsample image and extract RGB pixels using ImageMagick."""
243
- # Validate path contains no shell metacharacters (defense in depth)
244
- base_path = image_path.split("[")[0] if "[" in image_path else image_path
245
- if not os.path.isfile(base_path):
246
- raise FileNotFoundError(f"Image not found: {base_path}")
247
- cmd = [
248
- "magick", image_path,
249
- "-resize", f"{max_dim}x{max_dim}>",
250
- "-depth", "8",
251
- "txt:-",
252
- ]
253
- # cmd is a fixed list with validated image_path, shell=False by default
254
- result = subprocess.run( # nosec B603
255
- cmd, capture_output=True, text=True, timeout=30, check=False
256
- )
257
- if result.returncode != 0:
258
- raise RuntimeError(f"ImageMagick failed: {result.stderr}")
259
-
260
- pixels = []
261
- for line in result.stdout.splitlines():
262
- if line.startswith("#"):
263
- continue
264
- # Format: "x,y: (R,G,B) #RRGGBB srgb(R,G,B)" or with alpha
265
- parts = line.split("(")
266
- if len(parts) < 2:
267
- continue
268
- color_part = parts[1].split(")")[0]
269
- components = [c.strip() for c in color_part.split(",")]
270
- if len(components) >= 3:
271
- try:
272
- r, g, b = int(components[0]), int(components[1]), int(components[2])
273
- pixels.append((r, g, b))
274
- except ValueError:
275
- continue
276
- return pixels
277
-
278
-
279
- # ---------------------------------------------------------------------------
280
- # Theme generation from dominant colors
281
- # ---------------------------------------------------------------------------
282
-
283
- # ANSI hue targets in CIELAB hue angle
284
- ANSI_HUES = {
285
- "red": 30.0,
286
- "green": 145.0,
287
- "yellow": 95.0,
288
- "blue": 275.0,
289
- "magenta": 330.0,
290
- "cyan": 210.0,
291
- }
292
-
293
-
294
- def find_nearest_hue(hue: float) -> str:
295
- """Map a CIELAB hue angle to the nearest ANSI color name."""
296
- best_name = "red"
297
- best_dist = 360.0
298
- for name, target in ANSI_HUES.items():
299
- dist = min(abs(hue - target), 360 - abs(hue - target))
300
- if dist < best_dist:
301
- best_dist = dist
302
- best_name = name
303
- return best_name
304
-
305
-
306
- def adjust_lightness(lab: Tuple[float, float, float], target_L: float) -> Tuple[float, float, float]:
307
- """Adjust L* while preserving hue and chroma."""
308
- L, a, b = lab
309
- if L == 0:
310
- return (target_L, a, b)
311
- scale = target_L / L
312
- return (target_L, a * min(scale, 1.5), b * min(scale, 1.5))
313
-
314
-
315
- def ensure_contrast(
316
- fg_rgb: Tuple[int, int, int],
317
- bg_rgb: Tuple[int, int, int],
318
- min_ratio: float,
319
- is_dark: bool,
320
- ) -> Tuple[int, int, int]:
321
- """Adjust fg lightness until contrast ratio meets min_ratio against bg."""
322
- fl, fa, fb = rgb_to_lab(*fg_rgb)
323
- for _ in range(80):
324
- cr = contrast_ratio(lab_to_rgb(fl, fa, fb), bg_rgb)
325
- if cr >= min_ratio:
326
- break
327
- fl += 2.0 if is_dark else -2.0
328
- fl = max(0.0, min(100.0, fl))
329
- return lab_to_rgb(fl, fa, fb)
330
-
331
-
332
- # VS Code theming — Catppuccin only.
333
- #
334
- # The label must match `contributes.themes[].label` in the extension's
335
- # package.json exactly, and the icon id must match `contributes.iconThemes[].id`.
336
- # VS Code cannot resolve an unknown name: it keeps whatever is active and logs
337
- # an error the user never sees, which is how three wrong values here survived.
338
- #
339
- # Verified against catppuccin.catppuccin-vsc 3.19.0 and
340
- # catppuccin.catppuccin-vsc-icons 1.26.0:
341
- # themes Catppuccin Mocha | Macchiato | Frappe | Latte
342
- # iconThemes catppuccin-mocha | -macchiato | -frappe | -latte
343
- # productIconThemes (none — the extension registers none at all)
344
- #
345
- # Neovim still picks per-family (tokyonight/everforest/catppuccin) from the
346
- # wallpaper hue; only the VS Code surface is pinned, because a single editor
347
- # theme that is always installed beats three that mostly are not.
348
- VSCODE_DARK = "Catppuccin Mocha"
349
- VSCODE_LIGHT = "Catppuccin Latte"
350
- VSCODE_ICONS_DARK = "catppuccin-mocha"
351
- VSCODE_ICONS_LIGHT = "catppuccin-latte"
352
-
353
-
354
- def _nvim_from_hue(hue: float, is_dark: bool) -> Tuple[str, str]:
355
- """Map accent hue angle to nearest Neovim colorscheme."""
356
- if 60 <= hue < 150:
357
- return ("everforest", "hard" if is_dark else "soft")
358
- if 210 <= hue < 270:
359
- return ("tokyonight", "night" if is_dark else "day")
360
- return ("catppuccin", "mocha" if is_dark else "latte")
361
-
362
-
363
- def _macos_accent_from_hue(hue: float) -> int:
364
- """Map a CIELAB hue angle to a macOS accent-colour integer.
365
-
366
- Boundaries are calibrated to the CIELAB hues of Apple's own accent
367
- swatches (not HSL): red≈36°, orange≈67°, yellow≈87°, green≈144°,
368
- blue≈287°, purple≈317°. The previous HSL-style cutoffs pushed blue
369
- accents (~283°) into Purple and purple into Pink.
370
- """
371
- hue = hue % 360
372
- if hue < 15 or hue >= 345:
373
- return 6 # Pink / magenta
374
- if hue < 50:
375
- return 0 # Red
376
- if hue < 77:
377
- return 1 # Orange
378
- if hue < 110:
379
- return 2 # Yellow
380
- if hue < 200:
381
- return 3 # Green
382
- if hue < 300:
383
- return 4 # Blue (incl. teal-blue and navy)
384
- return 5 # Purple / indigo (300–345)
385
-
386
-
387
- def _env_hex(key: str, fallback: str) -> str:
388
- """A #rrggbb value from env, or the fallback when unset/invalid."""
389
- v = os.environ.get(key, "")
390
- if len(v) == 7 and v[0] == "#":
391
- try:
392
- hex_to_rgb(v)
393
- return v
394
- except ValueError:
395
- pass
396
- return fallback
397
-
398
-
399
- def _compute_bg_fg(clusters, is_dark):
400
- """Terminal background/foreground — fixed, engineered neutrals per mode.
401
-
402
- Deliberately NOT derived from the wallpaper: a terminal wants a
403
- high-contrast, low-eye-strain surface that stays stable across themes.
404
- Dark mode uses a deep neutral grey with off-white text; light mode a warm
405
- paper cream with charcoal text. The wallpaper still drives the accent and
406
- the 16 ANSI colours (which are computed against this bg for contrast).
407
- Override per mode via DOTFILES_TERM_BG_DARK / _FG_DARK / _BG_LIGHT /
408
- _FG_LIGHT.
409
- """
410
- if is_dark:
411
- bg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_BG_DARK", "#1e1e2e"))
412
- fg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_FG_DARK", "#d4d4d4"))
413
- else:
414
- bg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_BG_LIGHT", "#fbf1c7"))
415
- fg_rgb = hex_to_rgb(_env_hex("DOTFILES_TERM_FG_LIGHT", "#3c3836"))
416
-
417
- # Tint the engineered neutral toward the wallpaper's dominant hue.
418
- #
419
- # The lightness stays exactly where it was — that is what the docstring
420
- # above is protecting, and it is what keeps contrast and eye strain
421
- # predictable. Only a*/b* move, and only to TINT_CHROMA, which is small
422
- # enough to read as "this terminal belongs to that wallpaper" rather than
423
- # as a coloured background.
424
- #
425
- # Without this, term.bg had exactly two values across 228 themes and every
426
- # theme looked identical apart from one accent. Set DOTFILES_TERM_TINT=0
427
- # for the previous flat neutrals.
428
- if _env_hex("DOTFILES_TERM_TINT", "1") != "0":
429
- ranked = sorted(clusters, key=lambda c: c[1] * lab_chroma(*c[0]), reverse=True)
430
- src = ranked[0][0] if ranked and lab_chroma(*ranked[0][0]) >= 5.0 else None
431
- if src is not None:
432
- hue_c = lab_chroma(*src)
433
- if hue_c > 0:
434
- # Unit vector along the wallpaper's hue, scaled to a fixed,
435
- # deliberately low chroma. Dark surfaces take a touch more:
436
- # the same chroma reads weaker against a low-lightness ground.
437
- bg_chroma = 4.5 if is_dark else 3.0
438
- fg_chroma = 2.5 if is_dark else 2.0
439
- ua, ub = src[1] / hue_c, src[2] / hue_c
440
- bl = rgb_to_lab(*bg_rgb)[0]
441
- fl = rgb_to_lab(*fg_rgb)[0]
442
- bg_rgb = lab_to_rgb(bl, ua * bg_chroma, ub * bg_chroma)
443
- fg_rgb = lab_to_rgb(fl, ua * fg_chroma, ub * fg_chroma)
444
-
445
- bg_lab = rgb_to_lab(*bg_rgb)
446
- return bg_lab, bg_rgb, fg_rgb
447
-
448
-
449
- def _compute_accent(clusters, is_dark):
450
- """Select the accent from the wallpaper's DOMINANT chromatic colour.
451
-
452
- Rank clusters by population x chroma so the accent follows the *main*
453
- colour of the wallpaper (a large, colourful region) rather than the
454
- single most-saturated cluster — which is often a tiny vivid splash
455
- that doesn't represent the image. Near-neutral clusters score ~0 and
456
- are skipped; if the whole image is neutral we fall back to the most
457
- saturated cluster so the accent still carries a hue. The selected
458
- colour is then AAA-darkened below (ADR-009 contrast requirement)."""
459
- ranked = sorted(clusters, key=lambda c: c[1] * lab_chroma(*c[0]), reverse=True)
460
- accent_lab = ranked[0][0]
461
- if lab_chroma(*accent_lab) < 5.0:
462
- accent_lab = max(clusters, key=lambda c: lab_chroma(*c[0]))[0]
463
- if is_dark:
464
- accent_lab = (max(accent_lab[0], 35.0), accent_lab[1], accent_lab[2])
465
- else:
466
- accent_lab = (min(accent_lab[0], 45.0), accent_lab[1], accent_lab[2])
467
- # Darken until white text has 7:1 contrast (AAA)
468
- al, aa, ab = accent_lab
469
- for _ in range(80):
470
- if contrast_ratio((255, 255, 255), lab_to_rgb(al, aa, ab)) >= 7.0:
471
- break
472
- al = max(0.0, al - 2.0)
473
- return (al, aa, ab), lab_to_rgb(al, aa, ab)
474
-
475
-
476
- # A support colour that has to fall below this lightness to satisfy the 7:1
477
- # white-on-block requirement is not carrying colour any more. See the
478
- # rejection note in _compute_support_colours.
479
- SUPPORT_MIN_L = 25.0
480
-
481
- # Minimum perceptual distance between the three support colours, as dE*ab.
482
- #
483
- # This replaced a 12-degree hue-angle check, which let near-identical colours
484
- # through: hue angle is meaningless at low chroma, so three slate blues at
485
- # chroma ~13 could sit 12 degrees apart and still be the same colour to look
486
- # at. forest-dark's accent/secondary/tertiary measured dE 3.6 — against ~2.3
487
- # for "just noticeable" — while passing the hue rule comfortably.
488
- #
489
- # 10 is roughly where two colours read as clearly different rather than as
490
- # shades of one. Measured over the library, 68 of 228 themes had at least one
491
- # pair below it; those now reach further down the cluster ranking for a
492
- # candidate that is actually distinct, and only fall back to a synthetic
493
- # rotation when the wallpaper genuinely has nothing else to offer.
494
- SUPPORT_MIN_DE = 10.0
495
-
496
-
497
- def _aaa_darkened(lab, is_dark):
498
- """Clamp lightness per mode, then darken until white sits on it at 7:1.
499
-
500
- Shared by the selection filter and the final emission so a candidate is
501
- judged on exactly the colour it will become, not on the one it started
502
- as — checking the input and emitting the output was how a colour that
503
- passed the chroma filter still arrived at L*=14.8.
504
-
505
- A candidate that is ALREADY darker than SUPPORT_MIN_L is then lifted back
506
- toward it, one step at a time, stopping the moment another step would
507
- cost the 7:1 guarantee. Two different things can leave a support colour
508
- too dark: the darkening loop travelling a long way (a low-chroma
509
- candidate), and the cluster simply starting dark (dune-light's whole
510
- palette sits near L*17 and holds 14.3:1, so the loop never ran). The
511
- first is handled by rejecting the candidate; only the second can be
512
- lifted, and only where there is contrast headroom to pay for it.
513
- """
514
- L = max(lab[0], 35.0) if is_dark else min(lab[0], 45.0)
515
- a, b = lab[1], lab[2]
516
- for _ in range(80):
517
- if contrast_ratio((255, 255, 255), lab_to_rgb(L, a, b)) >= 7.0:
518
- break
519
- L = max(0.0, L - 2.0)
520
- while L < SUPPORT_MIN_L:
521
- nxt = min(SUPPORT_MIN_L, L + 1.0)
522
- if contrast_ratio((255, 255, 255), lab_to_rgb(nxt, a, b)) < 7.0:
523
- break
524
- L = nxt
525
- return (L, a, b)
526
-
527
-
528
- def _displayed(lab, is_dark):
529
- """The LAB of the colour that will actually be SHOWN.
530
-
531
- Cluster centroids routinely land outside sRGB — Tang's top three sit at
532
- chroma 197, 119 and 98, where sRGB tops out near 130 — and `lab_to_rgb`
533
- clamps them. Comparing the raw LAB values said those three were dE 79 and
534
- 49 apart; after clamping they were #a80000 and #b00000, dE 3.3. The
535
- distance test has to run on the clamped colour or it is measuring
536
- something the user never sees.
537
- """
538
- return rgb_to_lab(*lab_to_rgb(*_aaa_darkened(lab, is_dark)))
539
-
540
-
541
- def _compute_support_colours(clusters, accent_lab, is_dark):
542
- """The wallpaper's SECOND and THIRD chromatic colours, as UI accents.
543
-
544
- `_compute_accent` takes the top of the population x chroma ranking and
545
- throws the rest away, so a wallpaper contributed exactly one colour to
546
- the UI and everything else — status bars, separators, inactive states —
547
- fell back to fixed neutrals. These are the next two ranked clusters,
548
- put through the same AAA-darkening as the accent so white text sits on
549
- them at 7:1.
550
-
551
- Clusters within 12 degrees of a colour already chosen are skipped: two
552
- near-identical blues give no more information than one, and the point of
553
- a secondary is that it reads as different.
554
- """
555
- ranked = [c for c in sorted(clusters, key=lambda c: c[1] * lab_chroma(*c[0]), reverse=True)
556
- if lab_chroma(*c[0]) >= 5.0]
557
- # Compare the colours as they will be SHOWN, not as the clusters arrived:
558
- # the AAA-darkening moves lightness, which moves perceptual distance too.
559
- picked, chosen = [], [rgb_to_lab(*lab_to_rgb(*accent_lab))]
560
- for lab, _pop in ranked:
561
- final = _displayed(lab, is_dark)
562
- if any(lab_distance(final, o) < SUPPORT_MIN_DE for o in chosen):
563
- continue
564
- # Reject a candidate that only reaches 7:1 by going almost black.
565
- #
566
- # The AAA-darkening below drives lightness down until white sits on
567
- # the colour at 7:1. A low-chroma candidate has to travel a long way
568
- # to get there: bauhaus-light's second cluster landed at L*=14.8,
569
- # which reads as another shade of dark rather than as a colour, even
570
- # though its chroma of 8.0 passed the filter above.
571
- #
572
- # Measured across the library this affected 7 themes, all light
573
- # variants, where the surface is pale and the colours must darken to
574
- # sit on it. Raising the chroma floor instead would have cost 34
575
- # themes their support colours and fixed only one of these seven, so
576
- # the constraint belongs on the OUTCOME, not on the input.
577
- if final[0] < SUPPORT_MIN_L:
578
- continue
579
- picked.append(lab)
580
- chosen.append(final)
581
- if len(picked) == 2:
582
- break
583
- # A wallpaper with only one usable hue still needs two support colours, so
584
- # the accent's hue is rotated to invent them. Rotating blindly does not
585
- # work: a rotated hue at high chroma often falls outside sRGB, and
586
- # lab_to_rgb clamps it. Two different angles then clamp to nearly the same
587
- # colour — tang-light produced #695600 and #685600, dE 0.5 apart, both at
588
- # chroma 45. The fallback was the one path not checking its own output.
589
- #
590
- # So search: walk angles away from the accent, and drop chroma until the
591
- # result survives a round trip through sRGB (proof it is in gamut) and is
592
- # far enough from everything already chosen.
593
- while len(picked) < 2:
594
- base = picked[-1] if picked else accent_lab
595
- best = None
596
- for deg in (120, 90, 150, 60, 180, 40, 210, 30, 240, 270, 300):
597
- for scale in (1.0, 0.85, 0.7, 0.55, 0.4):
598
- c = lab_chroma(*base) * scale
599
- rad = math.radians((lab_hue(*base) + deg) % 360.0)
600
- cand_lab = (base[0], math.cos(rad) * c, math.sin(rad) * c)
601
- final = _displayed(cand_lab, is_dark)
602
- rgb = lab_to_rgb(*_aaa_darkened(cand_lab, is_dark))
603
- # Round trip: if the colour was out of gamut it was clamped,
604
- # and the clamped value will not convert back to what we asked
605
- # for. That is exactly how the duplicates were produced.
606
- if lab_distance(rgb_to_lab(*rgb), final) > 3.0:
607
- continue
608
- if final[0] < SUPPORT_MIN_L:
609
- continue
610
- d = min(lab_distance(final, o) for o in chosen)
611
- if d >= SUPPORT_MIN_DE:
612
- best = cand_lab
613
- break
614
- if best is None or d > min(lab_distance(_displayed(best, is_dark), o) for o in chosen):
615
- best = cand_lab
616
- if best is not None and min(
617
- lab_distance(_displayed(best, is_dark), o) for o in chosen
618
- ) >= SUPPORT_MIN_DE:
619
- break
620
- if best is None:
621
- best = (base[0], -base[1], -base[2]) # last resort: opposite hue
622
- picked.append(best)
623
- chosen.append(_displayed(best, is_dark))
624
-
625
- return (lab_to_rgb(*_aaa_darkened(picked[0], is_dark)),
626
- lab_to_rgb(*_aaa_darkened(picked[1], is_dark)))
627
-
628
-
629
- def _on_dark(lab, surfaces, min_ratio=4.5):
630
- """The same hue, light enough to be TEXT on a dark surface.
631
-
632
- `accent`, `secondary` and `tertiary` are darkened until white sits on
633
- them at 7:1 — they are background colours by construction, and painting
634
- them as text on the dark status bar measured as low as 1.67:1.
635
-
636
- This keeps the hue and chroma and walks lightness up instead, until the
637
- ratio holds against every surface the text can land on. Without it there
638
- is no way to show a wallpaper colour AS text: the choice would be
639
- between wallpaper colour and legibility.
640
- """
641
- L, a, b = lab
642
- for _ in range(120):
643
- rgb = lab_to_rgb(L, a, b)
644
- if all(contrast_ratio(rgb, s) >= min_ratio for s in surfaces):
645
- return rgb
646
- if L >= 100.0:
647
- break
648
- L = min(100.0, L + 1.5)
649
- return lab_to_rgb(L, a, b)
650
-
651
-
652
- def _muted_text(panel_rgb, border_rgb, bg_lab, is_dark):
653
- """Readable de-emphasised text — 4.5:1 against every surface it lands on.
654
-
655
- Not `term.c8`. c8 is ANSI bright-black and is supposed to be dim; its
656
- floor is 2.5:1 against bg, and tmux was painting the clock and the
657
- inactive window names with it at 2.12:1 and 2.45:1. Raising c8 would
658
- have made every terminal's dim colour less dim in order to fix a status
659
- bar, so this is a separate slot.
660
-
661
- Floored against BOTH panel and border, not just panel: the two differ in
662
- lightness, tmux draws muted text on each of them, and flooring against
663
- only the friendlier one left the other at 4.40:1 — a near miss is still
664
- a miss.
665
- """
666
- start = 62.0 if is_dark else 42.0
667
- seed = lab_to_rgb(start, bg_lab[1] * 0.5, bg_lab[2] * 0.5)
668
- out = seed
669
- for surface in (panel_rgb, border_rgb):
670
- out = ensure_contrast(out, surface, 4.5, is_dark)
671
- # ensure_contrast against the second surface can walk back toward the
672
- # first, so confirm rather than assume, and step until both hold.
673
- for _ in range(80):
674
- if all(contrast_ratio(out, s) >= 4.5 for s in (panel_rgb, border_rgb)):
675
- break
676
- l, a, b = rgb_to_lab(*out)
677
- l = min(100.0, l + 2.0) if is_dark else max(0.0, l - 2.0)
678
- out = lab_to_rgb(l, a, b)
679
- return out
680
-
681
-
682
- def _compute_panel_border(bg_lab, bg_rgb, is_dark):
683
- """Compute panel and border with enforced contrast ranges against bg."""
684
- if is_dark:
685
- panel_lab = (min(bg_lab[0] + 3, 100), bg_lab[1], bg_lab[2])
686
- border_lab = (bg_lab[0] + 8, bg_lab[1] * 0.5, bg_lab[2] * 0.5)
687
- else:
688
- panel_lab = (max(bg_lab[0] - 3, 0), bg_lab[1], bg_lab[2])
689
- border_lab = (bg_lab[0] - 6, bg_lab[1] * 0.3, bg_lab[2] * 0.3)
690
- panel_rgb = lab_to_rgb(*panel_lab)
691
- for _ in range(20):
692
- pr = contrast_ratio(panel_rgb, bg_rgb)
693
- if 1.03 <= pr <= 2.0:
694
- break
695
- if pr < 1.03:
696
- panel_lab = (panel_lab[0] + (2 if is_dark else -2), panel_lab[1], panel_lab[2])
697
- else:
698
- panel_lab = (panel_lab[0] + (-1 if is_dark else 1), panel_lab[1], panel_lab[2])
699
- panel_lab = (max(0, min(100, panel_lab[0])), panel_lab[1], panel_lab[2])
700
- panel_rgb = lab_to_rgb(*panel_lab)
701
- return panel_rgb, lab_to_rgb(*border_lab)
702
-
703
-
704
- def _build_ansi_color(base_lab, accent_lab, bg_rgb, is_dark):
705
- """Build normal + bright ANSI variant from a base Lab color."""
706
- normal_L = (
707
- max(55.0, min(75.0, base_lab[0])) if is_dark
708
- else max(30.0, min(50.0, base_lab[0]))
709
- )
710
- normal = adjust_lightness(base_lab, normal_L)
711
- if is_dark:
712
- # Dark bg: the bright variant pops by getting lighter.
713
- bright = adjust_lightness(base_lab, normal_L + 12)
714
- bright_min = 4.5
715
- else:
716
- # Light bg: a lighter bright would wash out against near-white, so
717
- # brighten by vividness at equal lightness instead — the bright is
718
- # never darker than the normal (matches Apple's light ANSI ramp,
719
- # where brights read as more saturated, not muddier).
720
- bright = (normal[0], normal[1] * 1.25, normal[2] * 1.25)
721
- bright_min = 7.0
722
- # WCAG AAA (7:1) for the chromatic slots on a light bg so coloured text
723
- # (paths, syntax) is unambiguously legible — AA (4.5:1) still read as washed
724
- # out on cream. Brights stay AAA too and differ from normals by saturation
725
- # (Apple's light ramp), not lightness. Dark mode keeps its lower floor
726
- # (light text on dark reads comfortably at a lower ratio).
727
- normal_min = 3.0 if is_dark else 7.0
728
- normal_rgb = ensure_contrast(lab_to_rgb(*normal), bg_rgb, normal_min, is_dark)
729
- bright_rgb = ensure_contrast(lab_to_rgb(*bright), bg_rgb, bright_min, is_dark)
730
- return normal_rgb, bright_rgb
731
-
732
-
733
- def _ansi_palette(clusters, accent_lab, bg_rgb, is_dark):
734
- """Generate the 6 chromatic ANSI hues (red, green, yellow, blue, magenta, cyan)."""
735
- chromatic = [(c, pop) for c, pop in clusters if lab_chroma(*c) > 10]
736
- hue_assignments: Dict[str, List[Tuple[float, float, float]]] = {h: [] for h in ANSI_HUES}
737
- for c_lab, _ in chromatic:
738
- hue_assignments[find_nearest_hue(lab_hue(*c_lab))].append(c_lab)
739
-
740
- accent_chroma = lab_chroma(*accent_lab)
741
- ansi = {}
742
- for hue_name, target_hue in ANSI_HUES.items():
743
- candidates = hue_assignments[hue_name]
744
- if candidates:
745
- base = max(candidates, key=lambda c: lab_chroma(*c))
746
- else:
747
- new_a = accent_chroma * math.cos(math.radians(target_hue))
748
- new_b = accent_chroma * math.sin(math.radians(target_hue))
749
- base = (50.0, new_a, new_b)
750
- ansi[hue_name] = _build_ansi_color(base, accent_lab, bg_rgb, is_dark)
751
- return ansi
752
-
753
-
754
- def _structural_colors(bg_lab, bg_rgb, is_dark):
755
- """Compute c0, c7, c8, c15 structural ANSI colors."""
756
- if is_dark:
757
- return (
758
- ensure_contrast(lab_to_rgb(bg_lab[0] + 10, bg_lab[1], bg_lab[2]), bg_rgb, 1.5, True),
759
- ensure_contrast(lab_to_rgb(75.0, bg_lab[1] * 0.1, bg_lab[2] * 0.1), bg_rgb, 5.0, True),
760
- ensure_contrast(lab_to_rgb(bg_lab[0] + 25, bg_lab[1], bg_lab[2]), bg_rgb, 2.5, True),
761
- ensure_contrast(lab_to_rgb(90.0, bg_lab[1] * 0.05, bg_lab[2] * 0.05), bg_rgb, 7.0, True),
762
- )
763
- # Light bg: the neutral ramp increases in lightness (c0 < c8 < c7 < c15).
764
- # c0/c8/c7 stay readable (>= AA); c15 ("bright white") is the LIGHTEST tone,
765
- # strictly lighter than c7 — bright-white text legibility is gated by fg, not
766
- # c15, and forcing c15 to the same floor as c7 made them converge (c15 ended
767
- # a hair darker, breaking the ramp). Start c15 well above c7 with only a mild
768
- # floor so it stays the lightest. (Terminals render the dark palette; this
769
- # ramp only feeds non-terminal light-palette consumers.)
770
- return (
771
- ensure_contrast(lab_to_rgb(18.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 7.0, False),
772
- ensure_contrast(lab_to_rgb(50.0, bg_lab[1] * 0.15, bg_lab[2] * 0.15), bg_rgb, 4.5, False),
773
- ensure_contrast(lab_to_rgb(35.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 4.5, False),
774
- ensure_contrast(lab_to_rgb(64.0, bg_lab[1] * 0.08, bg_lab[2] * 0.08), bg_rgb, 2.5, False),
775
- )
776
-
777
-
778
- def generate_theme(
779
- clusters: List[Tuple[Tuple[float, float, float], int]],
780
- name: str,
781
- is_dark: bool,
782
- ) -> Dict:
783
- """Generate a full theme definition from clustered dominant colors."""
784
- bg_lab, bg_rgb, fg_rgb = _compute_bg_fg(clusters, is_dark)
785
- accent_lab, accent_rgb = _compute_accent(clusters, is_dark)
786
- accent_text = (255, 255, 255)
787
- cursor_rgb = accent_rgb
788
-
789
- # Selection background
790
- if is_dark:
791
- sel_lab = (bg_lab[0] + 15, accent_lab[1] * 0.4, accent_lab[2] * 0.4)
792
- else:
793
- sel_lab = (bg_lab[0] - 12, accent_lab[1] * 0.3, accent_lab[2] * 0.3)
794
- sel_rgb = lab_to_rgb(*sel_lab)
795
-
796
- panel_rgb, border_rgb = _compute_panel_border(bg_lab, bg_rgb, is_dark)
797
- secondary_rgb, tertiary_rgb = _compute_support_colours(clusters, accent_lab, is_dark)
798
- muted_rgb = _muted_text(panel_rgb, border_rgb, bg_lab, is_dark)
799
- # Text-safe versions of the three chromatic colours, for consumers that
800
- # paint them as foreground on panel/border rather than as a block.
801
- _surfaces = (panel_rgb, border_rgb, bg_rgb)
802
- accent_on_rgb = _on_dark(rgb_to_lab(*accent_rgb), _surfaces)
803
- secondary_on_rgb = _on_dark(rgb_to_lab(*secondary_rgb), _surfaces)
804
- tertiary_on_rgb = _on_dark(rgb_to_lab(*tertiary_rgb), _surfaces)
805
- ansi = _ansi_palette(clusters, accent_lab, bg_rgb, is_dark)
806
- c0_rgb, c7_rgb, c8_rgb, c15_rgb = _structural_colors(bg_lab, bg_rgb, is_dark)
807
-
808
- accent_hue = lab_hue(*accent_lab)
809
- nvim_theme = _nvim_from_hue(accent_hue, is_dark)
810
- macos_accent = _macos_accent_from_hue(accent_hue)
811
-
812
- mode = "dark" if is_dark else "light"
813
-
814
- return {
815
- "name": name,
816
- "mode": mode,
817
- "family": name.rsplit("-", 1)[0] if name.endswith(f"-{mode}") else name,
818
- "macos_accent": macos_accent,
819
- "wallpaper": "", # Set by caller
820
- "source": "custom", # Set by caller
821
- "term": {
822
- "bg": rgb_to_hex(*bg_rgb),
823
- "fg": rgb_to_hex(*fg_rgb),
824
- "cursor": rgb_to_hex(*cursor_rgb),
825
- "cursor_text": rgb_to_hex(*bg_rgb),
826
- "sel_bg": rgb_to_hex(*sel_rgb),
827
- "sel_fg": rgb_to_hex(*fg_rgb),
828
- "c0": rgb_to_hex(*c0_rgb),
829
- "c1": rgb_to_hex(*ansi["red"][0]),
830
- "c2": rgb_to_hex(*ansi["green"][0]),
831
- "c3": rgb_to_hex(*ansi["yellow"][0]),
832
- "c4": rgb_to_hex(*ansi["blue"][0]),
833
- "c5": rgb_to_hex(*ansi["magenta"][0]),
834
- "c6": rgb_to_hex(*ansi["cyan"][0]),
835
- "c7": rgb_to_hex(*c7_rgb),
836
- "c8": rgb_to_hex(*c8_rgb),
837
- "c9": rgb_to_hex(*ansi["red"][1]),
838
- "c10": rgb_to_hex(*ansi["green"][1]),
839
- "c11": rgb_to_hex(*ansi["yellow"][1]),
840
- "c12": rgb_to_hex(*ansi["blue"][1]),
841
- "c13": rgb_to_hex(*ansi["magenta"][1]),
842
- "c14": rgb_to_hex(*ansi["cyan"][1]),
843
- "c15": rgb_to_hex(*c15_rgb),
844
- },
845
- "ui": {
846
- "accent": rgb_to_hex(*accent_rgb),
847
- "accent_text": rgb_to_hex(*accent_text),
848
- "error": rgb_to_hex(*ansi["red"][0]),
849
- "warning": rgb_to_hex(*ansi["yellow"][0]),
850
- "success": rgb_to_hex(*ansi["green"][0]),
851
- "info": rgb_to_hex(*ansi["blue"][0]),
852
- "panel": rgb_to_hex(*panel_rgb),
853
- "border": rgb_to_hex(*border_rgb),
854
- # The wallpaper's 2nd and 3rd chromatic colours. White sits on
855
- # either at 7:1, same as accent.
856
- "secondary": rgb_to_hex(*secondary_rgb),
857
- "tertiary": rgb_to_hex(*tertiary_rgb),
858
- # De-emphasised text that is still text: 4.5:1 against `panel`.
859
- "text_muted": rgb_to_hex(*muted_rgb),
860
- # Same hues, lightened until they are legible AS TEXT on panel,
861
- # border and bg (>= 4.5:1 on all three).
862
- "accent_on_surface": rgb_to_hex(*accent_on_rgb),
863
- "secondary_on_surface": rgb_to_hex(*secondary_on_rgb),
864
- "tertiary_on_surface": rgb_to_hex(*tertiary_on_rgb),
865
- },
866
- "app": {
867
- "nvim": nvim_theme[0],
868
- "nvim_style": nvim_theme[1],
869
- "lualine": nvim_theme[0],
870
- "gtk_theme": "Adwaita-dark" if is_dark else "Adwaita",
871
- "gtk_icon": "Papirus-Dark" if is_dark else "Papirus-Light",
872
- "gnome_shell": "",
873
- "gnome_gtk": "Adwaita-dark" if is_dark else "Adwaita",
874
- "vscode": VSCODE_DARK if is_dark else VSCODE_LIGHT,
875
- "vscode_dark": VSCODE_DARK,
876
- "vscode_light": VSCODE_LIGHT,
877
- "vscode_icons": VSCODE_ICONS_DARK if is_dark else VSCODE_ICONS_LIGHT,
878
- "cat_wallpaper": "",
879
- "starship_palette": f"catppuccin_{'mocha' if is_dark else 'latte'}",
880
- },
881
- }
882
-
883
-
884
- # ---------------------------------------------------------------------------
885
- # TOML output
886
- # ---------------------------------------------------------------------------
887
-
888
- def theme_to_toml(theme: Dict) -> str:
889
- """Render a theme dict as TOML sections."""
890
- name = theme["name"]
891
- lines = []
892
- lines.append(f'[themes.{name}]')
893
- lines.append(f'mode = "{theme["mode"]}"')
894
- lines.append(f'family = "{theme["family"]}"')
895
- lines.append(f'macos_accent = {theme["macos_accent"]}')
896
- lines.append(f'wallpaper = "{theme["wallpaper"]}"')
897
- lines.append(f'source = "{theme["source"]}"')
898
- lines.append("")
899
-
900
- lines.append(f"[themes.{name}.term]")
901
- for key in ["bg", "fg", "cursor", "cursor_text", "sel_bg", "sel_fg"]:
902
- lines.append(f'{key} = "{theme["term"][key]}"')
903
- for i in range(16):
904
- key = f"c{i}"
905
- pad = " " * (4 - len(key))
906
- lines.append(f'{key}{pad}= "{theme["term"][key]}"')
907
- lines.append("")
908
-
909
- lines.append(f"[themes.{name}.ui]")
910
- for key in ["accent", "accent_text", "secondary", "tertiary", "text_muted",
911
- "accent_on_surface", "secondary_on_surface", "tertiary_on_surface",
912
- "error", "warning", "success", "info", "panel", "border"]:
913
- lines.append(f'{key} = "{theme["ui"][key]}"')
914
- lines.append("")
915
-
916
- lines.append(f"[themes.{name}.app]")
917
- for key, val in theme["app"].items():
918
- lines.append(f'{key} = "{val}"')
919
-
920
- return "\n".join(lines)
921
-
922
-
923
- # ---------------------------------------------------------------------------
924
- # Main
925
- # ---------------------------------------------------------------------------
926
-
927
- def determine_mode(pixels: List[Tuple[int, int, int]]) -> bool:
928
- """Determine if image is dark (True) or light (False) from average luminance."""
929
- if not pixels:
930
- return True
931
- avg_lum = sum(relative_luminance(*p) for p in pixels) / len(pixels)
932
- return avg_lum < 0.35
933
-
934
-
935
- def derive_name(image_path: str) -> str:
936
- """Derive a theme name from the image filename."""
937
- base = os.path.splitext(os.path.basename(image_path))[0]
938
- # Normalize: lowercase, replace spaces with hyphens, strip non-alphanum
939
- name = base.lower().replace(" ", "-").replace("_", "-")
940
- name = "".join(c for c in name if c.isalnum() or c == "-")
941
- # Remove consecutive hyphens
942
- while "--" in name:
943
- name = name.replace("--", "-")
944
- return name.strip("-")
945
-
946
-
947
- def main():
948
- import argparse
949
-
950
- parser = argparse.ArgumentParser(
951
- description="Extract a terminal theme from a wallpaper image."
952
- )
953
- parser.add_argument("image", help="Path to wallpaper image")
954
- parser.add_argument("--name", help="Theme name (default: derived from filename)")
955
- parser.add_argument("--format", choices=["toml", "json"], default="toml")
956
- parser.add_argument("--clusters", type=int, default=8, help="Number of K-Means clusters")
957
- parser.add_argument("--source", choices=["system", "custom"], default="custom", help="Wallpaper source type")
958
- args = parser.parse_args()
959
-
960
- # Extract pixels
961
- try:
962
- pixels = extract_pixels(args.image)
963
- except FileNotFoundError as exc:
964
- print(f"Error: {exc}", file=sys.stderr)
965
- sys.exit(1)
966
- if not pixels:
967
- print("Error: no pixels extracted", file=sys.stderr)
968
- sys.exit(1)
969
-
970
- # Derive name
971
- name = args.name or derive_name(args.image)
972
-
973
- # Determine dark/light: trust name suffix if present, else detect from pixels
974
- if name.endswith("-dark"):
975
- is_dark = True
976
- elif name.endswith("-light"):
977
- is_dark = False
978
- else:
979
- is_dark = determine_mode(pixels)
980
- name += "-dark" if is_dark else "-light"
981
-
982
- # Subsample for speed — 2000 pixels is enough for accurate K-Means
983
- if len(pixels) > 2000:
984
- rng = random.Random(42) # nosec B311 — deterministic sampling
985
- pixels = rng.sample(pixels, 2000)
986
-
987
- # Convert to CIELAB
988
- lab_pixels = [rgb_to_lab(*p) for p in pixels]
989
-
990
- # Filter near-black and near-white to avoid muddy palettes
991
- filtered = [p for p in lab_pixels if 5 < p[0] < 95]
992
- if len(filtered) < len(lab_pixels) * 0.1:
993
- filtered = lab_pixels # Image is mostly black/white, use all
994
-
995
- # K-Means clustering
996
- clusters = kmeans_lab(filtered, k=args.clusters, runs=3)
997
-
998
- # Generate theme
999
- theme = generate_theme(clusters, name, is_dark)
1000
- wp_base = args.image.split("[")[0] if "[" in args.image else args.image
1001
- wp_abs = os.path.abspath(wp_base)
1002
- # Store home-relative with a `~` so the committed themes.toml is not tied to
1003
- # one machine's username. Consumers (wallpaper-sync.sh) expand `~/`. System
1004
- # wallpapers (/System/..., /usr/share/...) stay absolute — identical on
1005
- # every host anyway.
1006
- home = os.path.expanduser("~")
1007
- if wp_abs == home or wp_abs.startswith(home + os.sep):
1008
- wp_abs = "~" + wp_abs[len(home):]
1009
- theme["wallpaper"] = wp_abs
1010
- theme["source"] = args.source
1011
-
1012
- # Output
1013
- if args.format == "json":
1014
- print(json.dumps(theme, indent=2))
1015
- else:
1016
- print(theme_to_toml(theme))
1017
-
1018
-
1019
- if __name__ == "__main__":
1020
- main()