@sebastienrousseau/dotfiles 0.2.519 → 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 (306) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/LICENSE-APACHE +190 -0
  3. package/{LICENSE → LICENSE-MIT} +1 -1
  4. package/README.md +1172 -166
  5. package/install.sh +77 -11
  6. package/package.json +8 -8
  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/CNAME +0 -1
  12. package/docs/CONFIG_STRATEGY.md +0 -124
  13. package/docs/COPYRIGHT +0 -7
  14. package/docs/GOVERNANCE.md +0 -98
  15. package/docs/MAINTAINERS.md +0 -41
  16. package/docs/NAMING_CONVENTIONS.md +0 -102
  17. package/docs/OPENCODE.md +0 -127
  18. package/docs/README.md +0 -84
  19. package/docs/STRUCTURE.md +0 -102
  20. package/docs/adr/ADR-001-ci-cd-pipeline.md +0 -118
  21. package/docs/adr/ADR-002-shell-performance.md +0 -130
  22. package/docs/adr/ADR-003-security-first.md +0 -158
  23. package/docs/adr/ADR-004-cli-architecture.md +0 -171
  24. package/docs/adr/ADR-005-chezmoi-choice.md +0 -99
  25. package/docs/adr/ADR-006-shell-selection.md +0 -124
  26. package/docs/adr/ADR-007-multi-shell-parity.md +0 -62
  27. package/docs/adr/ADR-008-alias-system-architecture.md +0 -95
  28. package/docs/adr/ADR-009-wallpaper-driven-theming.md +0 -131
  29. package/docs/adr/ADR-010-starship-transient-prompt.md +0 -144
  30. package/docs/adr/ADR-011-nushell-tier3-keep.md +0 -144
  31. package/docs/adr/ADR-012-ai-fleet-local-proxy.md +0 -79
  32. package/docs/adr/README.md +0 -40
  33. package/docs/architecture/AI_COST_OPTIMIZATION.md +0 -144
  34. package/docs/architecture/ARCHITECTURE.md +0 -117
  35. package/docs/architecture/INTEROP.md +0 -44
  36. package/docs/architecture/REPO_LAYOUT.md +0 -241
  37. package/docs/architecture/WALKTHROUGH.md +0 -86
  38. package/docs/architecture/fleet-deployment.md +0 -77
  39. package/docs/archive/EUXIS_2026_REVIEW.md +0 -127
  40. package/docs/archive/LEGACY_ROADMAP.md +0 -6
  41. package/docs/archive/MILESTONE_v0.2.493.md +0 -47
  42. package/docs/archive/PLAN.md +0 -199
  43. package/docs/archive/REPO_AUDIT.md +0 -31
  44. package/docs/articles/.pages +0 -6
  45. package/docs/articles/2026-07-05-custom-mkdocs-material-dark-theme.md +0 -216
  46. package/docs/articles/2026-07-05-fish-startup-abbr.md +0 -153
  47. package/docs/articles/2026-07-05-master-to-main-rename-runbook.md +0 -128
  48. package/docs/articles/index.md +0 -36
  49. package/docs/guides/INSTALL.md +0 -144
  50. package/docs/guides/NEOVIM_IDE_GUIDE.md +0 -61
  51. package/docs/guides/THEMING.md +0 -230
  52. package/docs/guides/TROUBLESHOOTING.md +0 -176
  53. package/docs/guides/WSL2_NIX_TROUBLESHOOTING.md +0 -792
  54. package/docs/index.md +0 -132
  55. package/docs/interop/A2A.md +0 -39
  56. package/docs/interop/POWERSHELL.md +0 -102
  57. package/docs/manual/00-introduction.md +0 -89
  58. package/docs/manual/01-concepts/01-architecture.md +0 -138
  59. package/docs/manual/01-concepts/02-trust-model.md +0 -183
  60. package/docs/manual/01-concepts/03-theme-engine.md +0 -186
  61. package/docs/manual/01-concepts/04-fleet.md +0 -148
  62. package/docs/manual/01-concepts/05-self-healing.md +0 -204
  63. package/docs/manual/02-tutorials/01-first-install.md +0 -197
  64. package/docs/manual/02-tutorials/02-add-wallpaper.md +0 -216
  65. package/docs/manual/02-tutorials/03-create-profile.md +0 -244
  66. package/docs/manual/02-tutorials/04-encrypt-secret.md +0 -281
  67. package/docs/manual/02-tutorials/05-deploy-fleet.md +0 -283
  68. package/docs/manual/03-reference/01-dot-cli.md +0 -450
  69. package/docs/manual/03-reference/02-config-files.md +0 -265
  70. package/docs/manual/03-reference/03-environment.md +0 -124
  71. package/docs/manual/03-reference/04-templates.md +0 -190
  72. package/docs/manual/03-reference/05-feature-flags.md +0 -187
  73. package/docs/manual/04-cookbook/01-recipes.md +0 -285
  74. package/docs/manual/04-cookbook/02-troubleshooting.md +0 -351
  75. package/docs/manual/04-cookbook/03-faq.md +0 -175
  76. package/docs/manual/05-appendices/A-platform-matrix.md +0 -101
  77. package/docs/manual/05-appendices/B-security-checklist.md +0 -85
  78. package/docs/manual/05-appendices/C-glossary.md +0 -40
  79. package/docs/manual/05-appendices/D-bibliography.md +0 -58
  80. package/docs/manual/05-appendices/E-license.md +0 -38
  81. package/docs/manual/_toc.yml +0 -58
  82. package/docs/manual/command-index.md +0 -155
  83. package/docs/manual/concept-index.md +0 -168
  84. package/docs/manual/index.md +0 -66
  85. package/docs/operations/ARCHITECTURE_ROADMAP.md +0 -7
  86. package/docs/operations/ATTESTATION.md +0 -44
  87. package/docs/operations/CI_CADENCE.md +0 -107
  88. package/docs/operations/CI_COMPOSITES.md +0 -156
  89. package/docs/operations/COMPLETIONS.md +0 -123
  90. package/docs/operations/COVERAGE.md +0 -204
  91. package/docs/operations/DRIFT.md +0 -107
  92. package/docs/operations/HARD_AUDIT_2026.md +0 -631
  93. package/docs/operations/MAINTENANCE.md +0 -63
  94. package/docs/operations/MANIFEST.md +0 -127
  95. package/docs/operations/MIGRATION.md +0 -109
  96. package/docs/operations/OPERATIONS.md +0 -188
  97. package/docs/operations/PERFORMANCE.md +0 -133
  98. package/docs/operations/REGISTRY.md +0 -90
  99. package/docs/operations/RELEASE_PIPELINE.md +0 -128
  100. package/docs/operations/RELIABILITY.md +0 -122
  101. package/docs/operations/RFC_v0_2_503_reorganization.md +0 -280
  102. package/docs/operations/ROADMAP.md +0 -10
  103. package/docs/operations/ROADMAP_2026.md +0 -7
  104. package/docs/operations/ROADMAP_V0_2_503.md +0 -10
  105. package/docs/operations/TESTING.md +0 -216
  106. package/docs/operations/TRACEABILITY.md +0 -43
  107. package/docs/operations/TRUSTED_AGENT_WORKSTATION.md +0 -65
  108. package/docs/operations/VERSION_SYNC.md +0 -393
  109. package/docs/reference/ALIASES.md +0 -131
  110. package/docs/reference/ALIASES_CHEATSHEET.md +0 -32
  111. package/docs/reference/ALIASES_DEPRECATIONS.md +0 -13
  112. package/docs/reference/FEATURES.md +0 -66
  113. package/docs/reference/FONTS.md +0 -112
  114. package/docs/reference/POWERSHELL_PARITY.md +0 -82
  115. package/docs/reference/PROFILES.md +0 -69
  116. package/docs/reference/SCREENSHOTS.md +0 -121
  117. package/docs/reference/SCRIPTS.md +0 -71
  118. package/docs/reference/SUPPORT_MATRIX.md +0 -80
  119. package/docs/reference/THEMES.md +0 -117
  120. package/docs/reference/TOOLS.md +0 -110
  121. package/docs/reference/UTILS.md +0 -242
  122. package/docs/registry.json +0 -6
  123. package/docs/schema/dot-env-v1.json +0 -110
  124. package/docs/schema/dot-registry-v1.json +0 -33
  125. package/docs/security/AI_ACT_COMPLIANCE.md +0 -94
  126. package/docs/security/AUDIT_BYPASS.md +0 -103
  127. package/docs/security/AUTOMATION_SECRETS.md +0 -26
  128. package/docs/security/CI_EGRESS_ALLOWLIST.md +0 -127
  129. package/docs/security/CI_PINNING.md +0 -129
  130. package/docs/security/COMMIT_SIGNING.md +0 -138
  131. package/docs/security/COMPLIANCE.md +0 -458
  132. package/docs/security/DEPS_DEV_EXCEPTIONS.md +0 -86
  133. package/docs/security/DISCLOSURE.md +0 -130
  134. package/docs/security/ENCRYPTION.md +0 -57
  135. package/docs/security/FMEA.md +0 -159
  136. package/docs/security/FUZZING.md +0 -114
  137. package/docs/security/HISTORY_FILTERING.md +0 -132
  138. package/docs/security/INCIDENT_RESPONSE.md +0 -579
  139. package/docs/security/INSTALL_VERIFICATION.md +0 -122
  140. package/docs/security/KEYS.md +0 -49
  141. package/docs/security/KEY_ROTATION.md +0 -303
  142. package/docs/security/MCP_POLICY.md +0 -78
  143. package/docs/security/POLICY_RELEASES.md +0 -37
  144. package/docs/security/README.md +0 -28
  145. package/docs/security/SCORECARD.md +0 -195
  146. package/docs/security/SECRETS.md +0 -158
  147. package/docs/security/SECURITY.md +0 -45
  148. package/docs/security/SECURITY_CHECKLIST.md +0 -55
  149. package/docs/security/SHELL_EXEMPTIONS.md +0 -145
  150. package/docs/security/SOUP_REGISTER.md +0 -36
  151. package/docs/security/THREAT_MODEL.md +0 -130
  152. package/docs/security/VERIFICATION_VALIDATION.md +0 -228
  153. package/docs/security/VERIFY_RELEASE.md +0 -201
  154. package/docs/security/security-pubkey.asc +0 -15
  155. package/docs/stylesheets/extra.css +0 -444
  156. package/docs/themes/README.md +0 -10
  157. package/docs/themes/VISUAL_INTEGRITY_REPORT.md +0 -30
  158. package/docs/themes/hero-shot.svg +0 -78
  159. package/scripts/README.md +0 -123
  160. package/scripts/ci/check-copyright-headers.sh +0 -8
  161. package/scripts/ci/check-shell-preamble.sh +0 -8
  162. package/scripts/ci/guard-gitleaks-checkout.sh +0 -8
  163. package/scripts/demo/record.sh +0 -43
  164. package/scripts/diagnostics/a2a-conformance.sh +0 -163
  165. package/scripts/diagnostics/alias-governance.sh +0 -138
  166. package/scripts/diagnostics/aliases-cheatsheet.sh +0 -74
  167. package/scripts/diagnostics/aliases-manifest.sh +0 -77
  168. package/scripts/diagnostics/benchmark.sh +0 -408
  169. package/scripts/diagnostics/conflicts.sh +0 -73
  170. package/scripts/diagnostics/doctor-unified.sh +0 -39
  171. package/scripts/diagnostics/doctor.sh +0 -751
  172. package/scripts/diagnostics/drift-dashboard.sh +0 -202
  173. package/scripts/diagnostics/health.sh +0 -623
  174. package/scripts/diagnostics/history-analysis.sh +0 -86
  175. package/scripts/diagnostics/mcp-doctor.sh +0 -582
  176. package/scripts/diagnostics/perf.sh +0 -453
  177. package/scripts/diagnostics/scorecard.sh +0 -119
  178. package/scripts/diagnostics/secret-governance.sh +0 -65
  179. package/scripts/diagnostics/security-score.sh +0 -467
  180. package/scripts/diagnostics/smoke-test.sh +0 -88
  181. package/scripts/diagnostics/snapshot.sh +0 -90
  182. package/scripts/diagnostics/verify.sh +0 -108
  183. package/scripts/diagnostics/verify_state.sh +0 -73
  184. package/scripts/diagnostics/version-locks.sh +0 -94
  185. package/scripts/diagnostics/workstation-attestation.sh +0 -187
  186. package/scripts/dot/commands/agent.sh +0 -485
  187. package/scripts/dot/commands/agents.sh +0 -336
  188. package/scripts/dot/commands/ai.sh +0 -587
  189. package/scripts/dot/commands/aliases.sh +0 -277
  190. package/scripts/dot/commands/appearance.sh +0 -110
  191. package/scripts/dot/commands/completion.sh +0 -134
  192. package/scripts/dot/commands/core.sh +0 -217
  193. package/scripts/dot/commands/diagnostics.sh +0 -265
  194. package/scripts/dot/commands/env-emit.sh +0 -203
  195. package/scripts/dot/commands/fleet.sh +0 -688
  196. package/scripts/dot/commands/init.sh +0 -185
  197. package/scripts/dot/commands/lint.sh +0 -208
  198. package/scripts/dot/commands/manual.sh +0 -169
  199. package/scripts/dot/commands/meta.sh +0 -333
  200. package/scripts/dot/commands/patterns.sh +0 -55
  201. package/scripts/dot/commands/registry.sh +0 -419
  202. package/scripts/dot/commands/restore.sh +0 -232
  203. package/scripts/dot/commands/secrets.sh +0 -296
  204. package/scripts/dot/commands/security.sh +0 -102
  205. package/scripts/dot/commands/tools.sh +0 -556
  206. package/scripts/dot/data/alias-deprecations.tsv +0 -2
  207. package/scripts/dot/powershell/Dot.psm1 +0 -319
  208. package/scripts/fonts/install-nerd-fonts.sh +0 -75
  209. package/scripts/fonts/patch-fonts.sh +0 -36
  210. package/scripts/git-hooks/install.sh +0 -12
  211. package/scripts/git-hooks/pre-commit +0 -12
  212. package/scripts/git-hooks/pre-commit-audit.sh +0 -146
  213. package/scripts/git-hooks/pre-push +0 -105
  214. package/scripts/git-hooks/prepare-commit-msg +0 -29
  215. package/scripts/lib/secrets_provider.sh +0 -185
  216. package/scripts/ops/ai-setup.sh +0 -71
  217. package/scripts/ops/bundle.sh +0 -104
  218. package/scripts/ops/chaos.sh +0 -50
  219. package/scripts/ops/chezmoi-apply.sh +0 -333
  220. package/scripts/ops/chezmoi-diff.sh +0 -16
  221. package/scripts/ops/chezmoi-remove.sh +0 -46
  222. package/scripts/ops/chezmoi-update.sh +0 -63
  223. package/scripts/ops/heal-chezmoi.sh +0 -87
  224. package/scripts/ops/heal-system.sh +0 -129
  225. package/scripts/ops/heal-tools.sh +0 -297
  226. package/scripts/ops/heal.sh +0 -223
  227. package/scripts/ops/post-apply-repair.sh +0 -107
  228. package/scripts/ops/prewarm.sh +0 -128
  229. package/scripts/ops/release.sh +0 -262
  230. package/scripts/ops/rollback.sh +0 -604
  231. package/scripts/ops/setup.sh +0 -138
  232. package/scripts/ops/teleport.sh +0 -34
  233. package/scripts/qa/check-version-consistency.sh +0 -124
  234. package/scripts/qa/coverage-baseline.sh +0 -61
  235. package/scripts/qa/docs-coverage.sh +0 -112
  236. package/scripts/qa/examples-coverage.sh +0 -94
  237. package/scripts/qa/powershell-contract.ps1 +0 -95
  238. package/scripts/qa/reliability-audit.sh +0 -139
  239. package/scripts/qa/scorecard-snapshot.sh +0 -128
  240. package/scripts/qa/traceability-coverage.sh +0 -117
  241. package/scripts/qa/validate-examples.sh +0 -27
  242. package/scripts/qa/wsl-contract.sh +0 -12
  243. package/scripts/secrets/age-init.sh +0 -82
  244. package/scripts/secrets/create-secrets-file.sh +0 -46
  245. package/scripts/secrets/encrypt-ssh-key.sh +0 -44
  246. package/scripts/security/backup.sh +0 -58
  247. package/scripts/security/check-disclosure-key-expiry.sh +0 -111
  248. package/scripts/security/dns-doh.sh +0 -52
  249. package/scripts/security/encryption-check.sh +0 -55
  250. package/scripts/security/enforce-policies.sh +0 -335
  251. package/scripts/security/firewall.sh +0 -91
  252. package/scripts/security/lock-configs.sh +0 -67
  253. package/scripts/security/lock-screen.sh +0 -56
  254. package/scripts/security/manage-secrets.sh +0 -429
  255. package/scripts/security/ssh-cert.sh +0 -204
  256. package/scripts/security/telemetry-kill.sh +0 -51
  257. package/scripts/security/usb-safety.sh +0 -52
  258. package/scripts/theme/apply-gnome-theme.sh +0 -333
  259. package/scripts/theme/extract-heic-frames.sh +0 -115
  260. package/scripts/theme/extract-theme.py +0 -742
  261. package/scripts/theme/install-boot-logo.sh +0 -63
  262. package/scripts/theme/install-catppuccin-themes.sh +0 -371
  263. package/scripts/theme/install-cursors.sh +0 -26
  264. package/scripts/theme/install-file-icons.sh +0 -27
  265. package/scripts/theme/install-grub-theme.sh +0 -62
  266. package/scripts/theme/install-lock-icon.sh +0 -31
  267. package/scripts/theme/merge-wallpaper.sh +0 -146
  268. package/scripts/theme/rebuild-themes.sh +0 -544
  269. package/scripts/theme/switch.sh +0 -449
  270. package/scripts/theme/wallpaper-rotate.sh +0 -137
  271. package/scripts/theme/wallpaper-sync.sh +0 -690
  272. package/scripts/tools/cmatrix.sh +0 -22
  273. package/scripts/tools/detect-collisions.py +0 -103
  274. package/scripts/tools/emoji-picker.sh +0 -49
  275. package/scripts/tools/figlet-banner.sh +0 -19
  276. package/scripts/tools/log-rotate.sh +0 -31
  277. package/scripts/tools/lolcat-wrap.sh +0 -20
  278. package/scripts/tools/pipes.sh +0 -49
  279. package/scripts/tuning/linux.sh +0 -186
  280. package/scripts/tuning/macos.sh +0 -56
  281. package/scripts/uninstall.sh +0 -86
  282. package/scripts/version-sync.sh +0 -654
  283. package/templates/chezmoi-data/geekom-a9.toml.example +0 -21
  284. package/templates/chezmoi-data/mac-m1.toml.example +0 -16
  285. package/templates/chezmoi-data/mac-t2-linux.toml.example +0 -21
  286. package/templates/chezmoi-data/surface-pro-7p.toml.example +0 -21
  287. package/templates/projects/go/.github/workflows/ci.yml +0 -31
  288. package/templates/projects/go/README.md +0 -7
  289. package/templates/projects/go/cmd/__PROJECT_NAME__/main.go +0 -8
  290. package/templates/projects/go/go.mod +0 -3
  291. package/templates/projects/go/go.sum +0 -0
  292. package/templates/projects/molecule/README.md +0 -7
  293. package/templates/projects/molecule/converge.yml +0 -7
  294. package/templates/projects/molecule/molecule.yml +0 -16
  295. package/templates/projects/node/.github/workflows/ci.yml +0 -30
  296. package/templates/projects/node/README.md +0 -7
  297. package/templates/projects/node/package-lock.json +0 -12
  298. package/templates/projects/node/package.json +0 -10
  299. package/templates/projects/node/src/index.js +0 -3
  300. package/templates/projects/packer/README.md +0 -15
  301. package/templates/projects/packer/main.pkr.hcl +0 -15
  302. package/templates/projects/python/.github/workflows/ci.yml +0 -34
  303. package/templates/projects/python/README.md +0 -7
  304. package/templates/projects/python/pyproject.toml +0 -25
  305. package/templates/projects/python/src/__PROJECT_NAME__/__init__.py +0 -2
  306. package/templates/projects/python/tests/test_basic.py +0 -3
@@ -1,742 +0,0 @@
1
- #!/usr/bin/env python3
2
- # SPDX-License-Identifier: 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
- def _nvim_from_hue(hue: float, is_dark: bool) -> Tuple[str, str]:
333
- """Map accent hue angle to nearest Neovim colorscheme."""
334
- if 60 <= hue < 150:
335
- return ("everforest", "hard" if is_dark else "soft")
336
- if 210 <= hue < 270:
337
- return ("tokyonight", "night" if is_dark else "day")
338
- return ("catppuccin", "mocha" if is_dark else "latte")
339
-
340
-
341
- def _macos_accent_from_hue(hue: float) -> int:
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:
353
- return 0 # Red
354
- if hue < 77:
355
- return 1 # Orange
356
- if hue < 110:
357
- return 2 # Yellow
358
- if hue < 200:
359
- return 3 # Green
360
- if hue < 300:
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
375
-
376
-
377
- def _compute_bg_fg(clusters, is_dark):
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
389
- if is_dark:
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"))
392
- else:
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)
396
- return bg_lab, bg_rgb, fg_rgb
397
-
398
-
399
- def _compute_accent(clusters, is_dark):
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]
413
- if is_dark:
414
- accent_lab = (max(accent_lab[0], 35.0), accent_lab[1], accent_lab[2])
415
- else:
416
- accent_lab = (min(accent_lab[0], 45.0), accent_lab[1], accent_lab[2])
417
- # Darken until white text has 7:1 contrast (AAA)
418
- al, aa, ab = accent_lab
419
- for _ in range(80):
420
- if contrast_ratio((255, 255, 255), lab_to_rgb(al, aa, ab)) >= 7.0:
421
- break
422
- al = max(0.0, al - 2.0)
423
- return (al, aa, ab), lab_to_rgb(al, aa, ab)
424
-
425
-
426
- def _compute_panel_border(bg_lab, bg_rgb, is_dark):
427
- """Compute panel and border with enforced contrast ranges against bg."""
428
- if is_dark:
429
- panel_lab = (min(bg_lab[0] + 3, 100), bg_lab[1], bg_lab[2])
430
- border_lab = (bg_lab[0] + 8, bg_lab[1] * 0.5, bg_lab[2] * 0.5)
431
- else:
432
- panel_lab = (max(bg_lab[0] - 3, 0), bg_lab[1], bg_lab[2])
433
- border_lab = (bg_lab[0] - 6, bg_lab[1] * 0.3, bg_lab[2] * 0.3)
434
- panel_rgb = lab_to_rgb(*panel_lab)
435
- for _ in range(20):
436
- pr = contrast_ratio(panel_rgb, bg_rgb)
437
- if 1.03 <= pr <= 2.0:
438
- break
439
- if pr < 1.03:
440
- panel_lab = (panel_lab[0] + (2 if is_dark else -2), panel_lab[1], panel_lab[2])
441
- else:
442
- panel_lab = (panel_lab[0] + (-1 if is_dark else 1), panel_lab[1], panel_lab[2])
443
- panel_lab = (max(0, min(100, panel_lab[0])), panel_lab[1], panel_lab[2])
444
- panel_rgb = lab_to_rgb(*panel_lab)
445
- return panel_rgb, lab_to_rgb(*border_lab)
446
-
447
-
448
- def _build_ansi_color(base_lab, accent_lab, bg_rgb, is_dark):
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)
455
- if is_dark:
456
- # Dark bg: the bright variant pops by getting lighter.
457
- bright = adjust_lightness(base_lab, normal_L + 12)
458
- bright_min = 4.5
459
- else:
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)
474
- return normal_rgb, bright_rgb
475
-
476
-
477
- def _ansi_palette(clusters, accent_lab, bg_rgb, is_dark):
478
- """Generate the 6 chromatic ANSI hues (red, green, yellow, blue, magenta, cyan)."""
479
- chromatic = [(c, pop) for c, pop in clusters if lab_chroma(*c) > 10]
480
- hue_assignments: Dict[str, List[Tuple[float, float, float]]] = {h: [] for h in ANSI_HUES}
481
- for c_lab, _ in chromatic:
482
- hue_assignments[find_nearest_hue(lab_hue(*c_lab))].append(c_lab)
483
-
484
- accent_chroma = lab_chroma(*accent_lab)
485
- ansi = {}
486
- for hue_name, target_hue in ANSI_HUES.items():
487
- candidates = hue_assignments[hue_name]
488
- if candidates:
489
- base = max(candidates, key=lambda c: lab_chroma(*c))
490
- else:
491
- new_a = accent_chroma * math.cos(math.radians(target_hue))
492
- new_b = accent_chroma * math.sin(math.radians(target_hue))
493
- base = (50.0, new_a, new_b)
494
- ansi[hue_name] = _build_ansi_color(base, accent_lab, bg_rgb, is_dark)
495
- return ansi
496
-
497
-
498
- def _structural_colors(bg_lab, bg_rgb, is_dark):
499
- """Compute c0, c7, c8, c15 structural ANSI colors."""
500
- if is_dark:
501
- return (
502
- ensure_contrast(lab_to_rgb(bg_lab[0] + 10, bg_lab[1], bg_lab[2]), bg_rgb, 1.5, True),
503
- ensure_contrast(lab_to_rgb(75.0, bg_lab[1] * 0.1, bg_lab[2] * 0.1), bg_rgb, 5.0, True),
504
- ensure_contrast(lab_to_rgb(bg_lab[0] + 25, bg_lab[1], bg_lab[2]), bg_rgb, 2.5, True),
505
- ensure_contrast(lab_to_rgb(90.0, bg_lab[1] * 0.05, bg_lab[2] * 0.05), bg_rgb, 7.0, True),
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.)
514
- return (
515
- ensure_contrast(lab_to_rgb(18.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 7.0, False),
516
- ensure_contrast(lab_to_rgb(50.0, bg_lab[1] * 0.15, bg_lab[2] * 0.15), bg_rgb, 4.5, False),
517
- ensure_contrast(lab_to_rgb(35.0, bg_lab[1] * 0.2, bg_lab[2] * 0.2), bg_rgb, 4.5, False),
518
- ensure_contrast(lab_to_rgb(64.0, bg_lab[1] * 0.08, bg_lab[2] * 0.08), bg_rgb, 2.5, False),
519
- )
520
-
521
-
522
- def generate_theme(
523
- clusters: List[Tuple[Tuple[float, float, float], int]],
524
- name: str,
525
- is_dark: bool,
526
- ) -> Dict:
527
- """Generate a full theme definition from clustered dominant colors."""
528
- bg_lab, bg_rgb, fg_rgb = _compute_bg_fg(clusters, is_dark)
529
- accent_lab, accent_rgb = _compute_accent(clusters, is_dark)
530
- accent_text = (255, 255, 255)
531
- cursor_rgb = accent_rgb
532
-
533
- # Selection background
534
- if is_dark:
535
- sel_lab = (bg_lab[0] + 15, accent_lab[1] * 0.4, accent_lab[2] * 0.4)
536
- else:
537
- sel_lab = (bg_lab[0] - 12, accent_lab[1] * 0.3, accent_lab[2] * 0.3)
538
- sel_rgb = lab_to_rgb(*sel_lab)
539
-
540
- panel_rgb, border_rgb = _compute_panel_border(bg_lab, bg_rgb, is_dark)
541
- ansi = _ansi_palette(clusters, accent_lab, bg_rgb, is_dark)
542
- c0_rgb, c7_rgb, c8_rgb, c15_rgb = _structural_colors(bg_lab, bg_rgb, is_dark)
543
-
544
- accent_hue = lab_hue(*accent_lab)
545
- nvim_theme = _nvim_from_hue(accent_hue, is_dark)
546
- macos_accent = _macos_accent_from_hue(accent_hue)
547
-
548
- mode = "dark" if is_dark else "light"
549
-
550
- return {
551
- "name": name,
552
- "mode": mode,
553
- "family": name.rsplit("-", 1)[0] if name.endswith(f"-{mode}") else name,
554
- "macos_accent": macos_accent,
555
- "wallpaper": "", # Set by caller
556
- "source": "custom", # Set by caller
557
- "term": {
558
- "bg": rgb_to_hex(*bg_rgb),
559
- "fg": rgb_to_hex(*fg_rgb),
560
- "cursor": rgb_to_hex(*cursor_rgb),
561
- "cursor_text": rgb_to_hex(*bg_rgb),
562
- "sel_bg": rgb_to_hex(*sel_rgb),
563
- "sel_fg": rgb_to_hex(*fg_rgb),
564
- "c0": rgb_to_hex(*c0_rgb),
565
- "c1": rgb_to_hex(*ansi["red"][0]),
566
- "c2": rgb_to_hex(*ansi["green"][0]),
567
- "c3": rgb_to_hex(*ansi["yellow"][0]),
568
- "c4": rgb_to_hex(*ansi["blue"][0]),
569
- "c5": rgb_to_hex(*ansi["magenta"][0]),
570
- "c6": rgb_to_hex(*ansi["cyan"][0]),
571
- "c7": rgb_to_hex(*c7_rgb),
572
- "c8": rgb_to_hex(*c8_rgb),
573
- "c9": rgb_to_hex(*ansi["red"][1]),
574
- "c10": rgb_to_hex(*ansi["green"][1]),
575
- "c11": rgb_to_hex(*ansi["yellow"][1]),
576
- "c12": rgb_to_hex(*ansi["blue"][1]),
577
- "c13": rgb_to_hex(*ansi["magenta"][1]),
578
- "c14": rgb_to_hex(*ansi["cyan"][1]),
579
- "c15": rgb_to_hex(*c15_rgb),
580
- },
581
- "ui": {
582
- "accent": rgb_to_hex(*accent_rgb),
583
- "accent_text": rgb_to_hex(*accent_text),
584
- "error": rgb_to_hex(*ansi["red"][0]),
585
- "warning": rgb_to_hex(*ansi["yellow"][0]),
586
- "success": rgb_to_hex(*ansi["green"][0]),
587
- "info": rgb_to_hex(*ansi["blue"][0]),
588
- "panel": rgb_to_hex(*panel_rgb),
589
- "border": rgb_to_hex(*border_rgb),
590
- },
591
- "app": {
592
- "nvim": nvim_theme[0],
593
- "nvim_style": nvim_theme[1],
594
- "lualine": nvim_theme[0],
595
- "gtk_theme": "Adwaita-dark" if is_dark else "Adwaita",
596
- "gtk_icon": "Papirus-Dark" if is_dark else "Papirus-Light",
597
- "gnome_shell": "",
598
- "gnome_gtk": "Adwaita-dark" if is_dark else "Adwaita",
599
- "vscode": f"{nvim_theme[0].replace('-', ' ').title()} {'Mocha' if is_dark else 'Latte'}",
600
- "vscode_dark": f"{nvim_theme[0].replace('-', ' ').title()} Mocha",
601
- "vscode_light": f"{nvim_theme[0].replace('-', ' ').title()} Latte",
602
- "cat_wallpaper": "",
603
- "starship_palette": f"catppuccin_{'mocha' if is_dark else 'latte'}",
604
- },
605
- }
606
-
607
-
608
- # ---------------------------------------------------------------------------
609
- # TOML output
610
- # ---------------------------------------------------------------------------
611
-
612
- def theme_to_toml(theme: Dict) -> str:
613
- """Render a theme dict as TOML sections."""
614
- name = theme["name"]
615
- lines = []
616
- lines.append(f'[themes.{name}]')
617
- lines.append(f'mode = "{theme["mode"]}"')
618
- lines.append(f'family = "{theme["family"]}"')
619
- lines.append(f'macos_accent = {theme["macos_accent"]}')
620
- lines.append(f'wallpaper = "{theme["wallpaper"]}"')
621
- lines.append(f'source = "{theme["source"]}"')
622
- lines.append("")
623
-
624
- lines.append(f"[themes.{name}.term]")
625
- for key in ["bg", "fg", "cursor", "cursor_text", "sel_bg", "sel_fg"]:
626
- lines.append(f'{key} = "{theme["term"][key]}"')
627
- for i in range(16):
628
- key = f"c{i}"
629
- pad = " " * (4 - len(key))
630
- lines.append(f'{key}{pad}= "{theme["term"][key]}"')
631
- lines.append("")
632
-
633
- lines.append(f"[themes.{name}.ui]")
634
- for key in ["accent", "accent_text", "error", "warning", "success", "info", "panel", "border"]:
635
- lines.append(f'{key} = "{theme["ui"][key]}"')
636
- lines.append("")
637
-
638
- lines.append(f"[themes.{name}.app]")
639
- for key, val in theme["app"].items():
640
- lines.append(f'{key} = "{val}"')
641
-
642
- return "\n".join(lines)
643
-
644
-
645
- # ---------------------------------------------------------------------------
646
- # Main
647
- # ---------------------------------------------------------------------------
648
-
649
- def determine_mode(pixels: List[Tuple[int, int, int]]) -> bool:
650
- """Determine if image is dark (True) or light (False) from average luminance."""
651
- if not pixels:
652
- return True
653
- avg_lum = sum(relative_luminance(*p) for p in pixels) / len(pixels)
654
- return avg_lum < 0.35
655
-
656
-
657
- def derive_name(image_path: str) -> str:
658
- """Derive a theme name from the image filename."""
659
- base = os.path.splitext(os.path.basename(image_path))[0]
660
- # Normalize: lowercase, replace spaces with hyphens, strip non-alphanum
661
- name = base.lower().replace(" ", "-").replace("_", "-")
662
- name = "".join(c for c in name if c.isalnum() or c == "-")
663
- # Remove consecutive hyphens
664
- while "--" in name:
665
- name = name.replace("--", "-")
666
- return name.strip("-")
667
-
668
-
669
- def main():
670
- import argparse
671
-
672
- parser = argparse.ArgumentParser(
673
- description="Extract a terminal theme from a wallpaper image."
674
- )
675
- parser.add_argument("image", help="Path to wallpaper image")
676
- parser.add_argument("--name", help="Theme name (default: derived from filename)")
677
- parser.add_argument("--format", choices=["toml", "json"], default="toml")
678
- parser.add_argument("--clusters", type=int, default=8, help="Number of K-Means clusters")
679
- parser.add_argument("--source", choices=["system", "custom"], default="custom", help="Wallpaper source type")
680
- args = parser.parse_args()
681
-
682
- # Extract pixels
683
- try:
684
- pixels = extract_pixels(args.image)
685
- except FileNotFoundError as exc:
686
- print(f"Error: {exc}", file=sys.stderr)
687
- sys.exit(1)
688
- if not pixels:
689
- print("Error: no pixels extracted", file=sys.stderr)
690
- sys.exit(1)
691
-
692
- # Derive name
693
- name = args.name or derive_name(args.image)
694
-
695
- # Determine dark/light: trust name suffix if present, else detect from pixels
696
- if name.endswith("-dark"):
697
- is_dark = True
698
- elif name.endswith("-light"):
699
- is_dark = False
700
- else:
701
- is_dark = determine_mode(pixels)
702
- name += "-dark" if is_dark else "-light"
703
-
704
- # Subsample for speed — 2000 pixels is enough for accurate K-Means
705
- if len(pixels) > 2000:
706
- rng = random.Random(42) # nosec B311 — deterministic sampling
707
- pixels = rng.sample(pixels, 2000)
708
-
709
- # Convert to CIELAB
710
- lab_pixels = [rgb_to_lab(*p) for p in pixels]
711
-
712
- # Filter near-black and near-white to avoid muddy palettes
713
- filtered = [p for p in lab_pixels if 5 < p[0] < 95]
714
- if len(filtered) < len(lab_pixels) * 0.1:
715
- filtered = lab_pixels # Image is mostly black/white, use all
716
-
717
- # K-Means clustering
718
- clusters = kmeans_lab(filtered, k=args.clusters, runs=3)
719
-
720
- # Generate theme
721
- theme = generate_theme(clusters, name, is_dark)
722
- wp_base = args.image.split("[")[0] if "[" in args.image else args.image
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
732
- theme["source"] = args.source
733
-
734
- # Output
735
- if args.format == "json":
736
- print(json.dumps(theme, indent=2))
737
- else:
738
- print(theme_to_toml(theme))
739
-
740
-
741
- if __name__ == "__main__":
742
- main()