@tyroneross/build-loop 0.36.0 → 0.43.0

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 (270) hide show
  1. package/.agents/plugins/marketplace.json +2 -2
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.cursor/rules/build-loop-surface.mdc +12 -11
  6. package/.cursor/rules/skill-index.mdc +33 -0
  7. package/AGENTS.md +213 -34
  8. package/README.md +99 -31
  9. package/agents/advisor.md +4 -4
  10. package/agents/alignment-checker.md +2 -2
  11. package/agents/architecture-scout.md +4 -4
  12. package/agents/build-orchestrator.md +38 -36
  13. package/agents/database-assessor.md +11 -5
  14. package/agents/design-contract-specialist.md +8 -8
  15. package/agents/fact-checker.md +13 -3
  16. package/agents/fix-critique.md +2 -2
  17. package/agents/independent-auditor.md +60 -7
  18. package/agents/leak-scanner.md +82 -0
  19. package/agents/overfitting-reviewer.md +2 -2
  20. package/agents/plan-critic.md +1 -1
  21. package/agents/promotion-reviewer.md +5 -5
  22. package/agents/retrospective-synthesizer.md +138 -35
  23. package/agents/scope-auditor.md +82 -11
  24. package/agents/security-reviewer.md +56 -2
  25. package/agents/self-improvement-architect.md +17 -3
  26. package/agents/transcript-pattern-miner.md +5 -5
  27. package/agents/ui-validator.md +1 -1
  28. package/bin/build-loop-debugger.js +143 -0
  29. package/bin/build-loop-install.js +1 -4
  30. package/bin/build-loop-load-probe.js +345 -0
  31. package/codex-skills/build-loop/SKILL.md +28 -6
  32. package/commands/feedback.md +37 -0
  33. package/dist/src/interactive-verifier.d.ts +1 -14
  34. package/dist/src/interactive-verifier.d.ts.map +1 -1
  35. package/dist/src/interactive-verifier.js +6 -113
  36. package/dist/src/interactive-verifier.js.map +1 -1
  37. package/dist/src/quality.d.ts +5 -0
  38. package/dist/src/quality.d.ts.map +1 -0
  39. package/dist/src/quality.js +81 -0
  40. package/dist/src/quality.js.map +1 -0
  41. package/dist/src/storage.d.ts.map +1 -1
  42. package/dist/src/storage.js +37 -3
  43. package/dist/src/storage.js.map +1 -1
  44. package/docs/agent-surface-policy.md +35 -31
  45. package/docs/memory-setup.md +19 -0
  46. package/hooks/git/pre-push +65 -4
  47. package/hooks/hooks.json +95 -38
  48. package/hooks/pre-commit +20 -1
  49. package/hooks/pre-edit-rally-point.sh +10 -3
  50. package/hooks/session-start-codex-hook-trust.sh +30 -0
  51. package/hooks/session-start-git-hooks.sh +3 -1
  52. package/hooks/session-start-rally-point.sh +52 -4
  53. package/hooks/session-start-worktree-gc.sh +47 -94
  54. package/hooks/stop-transcript-sweep.sh +173 -0
  55. package/hooks/test_closeout.sh +14 -2
  56. package/package.json +8 -7
  57. package/scripts/README.md +1 -1
  58. package/scripts/_paths.py +65 -0
  59. package/scripts/groundwork_exchange.py +1012 -0
  60. package/scripts/install_memory.py +33 -1
  61. package/scripts/lessons_index/ingest.py +13 -2
  62. package/scripts/lessons_index/query.py +36 -13
  63. package/scripts/memory_context/__init__.py +108 -14
  64. package/scripts/memory_graph/__init__.py +5 -1
  65. package/scripts/project_resolver.py +42 -36
  66. package/scripts/sync_plugin_cache.py +37 -2
  67. package/skills/agent-rally-point/SKILL.md +46 -0
  68. package/skills/api-registry-bridge/SKILL.md +1 -1
  69. package/skills/architecture/dead/SKILL.md +1 -1
  70. package/skills/architecture/impact/SKILL.md +1 -1
  71. package/skills/architecture/review/SKILL.md +1 -1
  72. package/skills/architecture/rules/SKILL.md +3 -3
  73. package/skills/architecture/scan/SKILL.md +1 -1
  74. package/skills/architecture/trace/SKILL.md +1 -1
  75. package/skills/attribution-standard/SKILL.md +6 -6
  76. package/skills/auto-decision-capture/SKILL.md +31 -2
  77. package/skills/auto-finding-capture/SKILL.md +28 -1
  78. package/skills/build-loop/SKILL.md +131 -23
  79. package/skills/build-loop/fallbacks.md +16 -21
  80. package/skills/build-loop/phases/ui-validation.md +2 -2
  81. package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
  82. package/skills/build-loop/references/apple-native-planning.md +1 -1
  83. package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
  84. package/skills/build-loop/references/autonomy-dashboard.md +115 -0
  85. package/skills/build-loop/references/capability-routing.md +24 -2
  86. package/skills/build-loop/references/coordination.md +24 -6
  87. package/skills/build-loop/references/experiment-results-template.md +15 -3
  88. package/skills/build-loop/references/leadership.md +1 -1
  89. package/skills/build-loop/references/memory.md +14 -3
  90. package/skills/build-loop/references/modular-systems-pack.md +8 -0
  91. package/skills/build-loop/references/output-style.md +86 -0
  92. package/skills/build-loop/references/phase-1-assess.md +102 -2
  93. package/skills/build-loop/references/phase-2-plan.md +9 -1
  94. package/skills/build-loop/references/phase-3-execute.md +5 -2
  95. package/skills/build-loop/references/phase-4-review.md +85 -8
  96. package/skills/build-loop/references/phase-5-iterate.md +76 -8
  97. package/skills/build-loop/references/phase-6-learn.md +10 -17
  98. package/skills/build-loop/references/privileged-request-broker.md +254 -0
  99. package/skills/build-loop/references/resource-aware-execution.md +183 -0
  100. package/skills/build-loop/references/self-recursive-dev.md +2 -2
  101. package/skills/build-loop/references/status-output-format.md +207 -0
  102. package/skills/build-loop/references/verify-dispatch.md +56 -2
  103. package/skills/building-with-deepagents/SKILL.md +1 -1
  104. package/skills/claim-scope/SKILL.md +185 -0
  105. package/skills/color-engine/SKILL.md +103 -0
  106. package/skills/color-engine/_core.py +464 -0
  107. package/skills/color-engine/color_engine.py +175 -0
  108. package/skills/cost-rca/SKILL.md +61 -0
  109. package/skills/data-plane-worktrees/SKILL.md +139 -0
  110. package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
  111. package/skills/database-practice/SKILL.md +200 -0
  112. package/skills/database-practice/references/diagnostic-queries.sql +126 -0
  113. package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
  114. package/skills/database-practice/scripts/db_table_map.py +1244 -0
  115. package/skills/database-practice/scripts/test_db_table_map.py +514 -0
  116. package/skills/debug-loop/SKILL.md +36 -6
  117. package/skills/debugging-memory/SKILL.md +32 -430
  118. package/skills/debugging-memory/references/pattern-extraction.md +4 -4
  119. package/skills/debugging-memory/references/search.md +32 -120
  120. package/skills/debugging-memory/references/store.md +32 -126
  121. package/skills/debugging-memory/references/subagent-integration.md +1 -1
  122. package/skills/decision-queue/SKILL.md +251 -0
  123. package/skills/decision-queue/assets/template.html +1242 -0
  124. package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
  125. package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
  126. package/skills/defenseclaw-bridge/SKILL.md +2 -2
  127. package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
  128. package/skills/drain-proposals/SKILL.md +53 -0
  129. package/skills/focused-loop-builder/SKILL.md +31 -0
  130. package/skills/focused-loop-builder/references/spec-format.md +27 -0
  131. package/skills/handoff/SKILL.md +169 -8
  132. package/skills/ibr-bridge/SKILL.md +4 -1
  133. package/skills/knowledge/SKILL.md +26 -14
  134. package/skills/knowledge/references/review-mode.md +2 -3
  135. package/skills/knowledge/templates/madr-minimal.md +1 -1
  136. package/skills/mcp-builder/SKILL.md +1 -1
  137. package/skills/model-bakeoff/SKILL.md +48 -10
  138. package/skills/model-tiering/SKILL.md +92 -31
  139. package/skills/native-ax-driver/SKILL.md +38 -5
  140. package/skills/native-ax-driver/scripts/native_driver.py +278 -22
  141. package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
  142. package/skills/optimize/SKILL.md +1 -1
  143. package/skills/plugin-builder/SKILL.md +48 -1
  144. package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
  145. package/skills/plugin-builder/references/distribution.md +13 -2
  146. package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
  147. package/skills/plugin-tests/SKILL.md +2 -2
  148. package/skills/recursive-retrospective/SKILL.md +1 -1
  149. package/skills/repo-closeout/SKILL.md +17 -0
  150. package/skills/repo-closeout/agents/openai.yaml +4 -0
  151. package/skills/repo-maintenance/SKILL.md +179 -0
  152. package/skills/repo-maintenance/agents/openai.yaml +4 -0
  153. package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
  154. package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
  155. package/skills/repo-maintenance/references/safety-protocol.md +106 -0
  156. package/skills/repo-maintenance/references/stack-profiles.md +138 -0
  157. package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
  158. package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
  159. package/skills/repository-intelligence/SKILL.md +189 -0
  160. package/skills/repository-intelligence/agents/openai.yaml +4 -0
  161. package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
  162. package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
  163. package/skills/research/SKILL.md +12 -2
  164. package/skills/root-cause-analysis/SKILL.md +1 -1
  165. package/skills/runtime-parity-verification/SKILL.md +36 -1
  166. package/skills/security-methodology/SKILL.md +23 -10
  167. package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
  168. package/skills/security-methodology/references/cross-source-matrix.md +1 -1
  169. package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
  170. package/skills/security-scan/SKILL.md +55 -15
  171. package/skills/self-improve/SKILL.md +70 -50
  172. package/skills/silent-assumptions/SKILL.md +341 -0
  173. package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
  174. package/skills/spec-writing/SKILL.md +128 -24
  175. package/skills/spec-writing/scripts/check_checklist.py +114 -15
  176. package/skills/ui-design/SKILL.md +6 -4
  177. package/skills/ui-design/references/color-engine.md +132 -0
  178. package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
  179. package/skills/ui-design/references/ui-guidance-sources.md +1 -1
  180. package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
  181. package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
  182. package/plugin-artifacts/codex/AGENTS.md +0 -560
  183. package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
  184. package/plugin-artifacts/codex/LICENSE +0 -202
  185. package/plugin-artifacts/codex/README.md +0 -313
  186. package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
  187. package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
  188. package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
  189. package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
  190. package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
  191. package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
  192. package/plugin-artifacts/codex/references/backlog-system.md +0 -285
  193. package/plugin-artifacts/codex/references/capability-routing.md +0 -231
  194. package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
  195. package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
  196. package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
  197. package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
  198. package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
  199. package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
  200. package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
  201. package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
  202. package/plugin-artifacts/codex/references/leadership.md +0 -72
  203. package/plugin-artifacts/codex/references/memory-systems.md +0 -261
  204. package/plugin-artifacts/codex/references/memory.md +0 -313
  205. package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
  206. package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
  207. package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
  208. package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
  209. package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
  210. package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
  211. package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
  212. package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
  213. package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
  214. package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
  215. package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
  216. package/plugin-artifacts/codex/references/self-review.md +0 -234
  217. package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
  218. package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
  219. package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
  220. package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
  221. package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
  222. package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
  223. package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
  224. package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
  225. package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
  226. package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
  227. package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
  228. package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
  229. package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
  230. package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
  231. package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
  232. package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
  233. package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
  234. package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
  235. package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
  236. package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
  237. package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
  238. package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
  239. package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
  240. package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
  241. package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
  242. package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
  243. package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
  244. package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
  245. package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
  246. package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
  247. package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
  248. package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
  249. package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
  250. package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
  251. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
  252. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
  253. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
  254. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
  255. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
  256. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
  257. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
  258. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
  259. package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
  260. package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
  261. package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
  262. package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
  263. package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
  264. package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
  265. package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
  266. package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
  267. package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
  268. package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
  269. package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
  270. package/scripts/build_codex_plugin_artifact.py +0 -321
@@ -0,0 +1,464 @@
1
+ # VENDORED — do not hand-edit.
2
+ # source: groundwork/designer/color/relationships.py
3
+ # source_sha256: 1c79ae50d4789501aa966e82f0670d4b0dded6165cd72b8ae513fb1f90f7e983
4
+ # vendored: 2026-07-25
5
+ # Drift check: re-hash the source and compare. Pure stdlib; zero dependencies.
6
+ #!/usr/bin/env python3
7
+ # ───────────────────────────────────────────────────────────────────────
8
+ # Groundwork — color RELATIONSHIPS engine
9
+ #
10
+ # WHAT: a palette is not a list of colors — it is a vector of RELATIONSHIPS
11
+ # (contrast amounts, the neutral↔base↔accent chroma structure, hue deltas,
12
+ # tonal steps). Fix the relationships and rotate the anchor hue and you get
13
+ # an INFINITE family of distinct-but-equally-valid systems, because the
14
+ # *design* lives in the relationships, which are invariant under the
15
+ # rotation. This module maps those relationships to math and generates
16
+ # concrete, gamut-safe, contrast-verified palettes from a parameter vector.
17
+ #
18
+ # WHY OKLCH: relationships must be expressed in a PERCEPTUALLY UNIFORM space or
19
+ # "equal steps" and "this much contrast" don't mean what they say. OKLCH
20
+ # (Lightness, Chroma, Hue) — Björn Ottosson's OKLab in polar form — is that
21
+ # space. We convert OKLCH -> linear sRGB -> gamma sRGB -> hex, and compute
22
+ # WCAG contrast from the linear-light relative luminance we already have.
23
+ #
24
+ # THE RELATIONSHIP VECTOR (the knobs; see PARAMS below):
25
+ # anchor_hue the base hue everything is defined RELATIVE to
26
+ # accent_hue_delta accent = anchor + delta (30 analogous · 180 comp ·
27
+ # 150 split-comp · 120 triad — harmony geometry as a number)
28
+ # neutral_hue_delta tinted-neutral offset from anchor (often 0)
29
+ # neutral/base/accent chroma the chroma STRUCTURE (near-0 / moderate / high)
30
+ # surface_L background lightness (high=light mode, low=dark mode)
31
+ # on_surface_contrast target body-text contrast vs surface (e.g. 7.0) -> SOLVED
32
+ # accent_contrast target accent contrast vs surface (e.g. 4.5) -> SOLVED
33
+ # ramp_steps tonal steps per role
34
+ #
35
+ # Contrast is SOLVED, never eyeballed: given a target ratio we bisect on OKLCH-L
36
+ # to land the exact lightness that hits it. That is the whole point — the
37
+ # relationship (the ratio) is the input; the color is the output.
38
+ #
39
+ # Pure Python stdlib (math only). No numpy, no third-party color libs.
40
+ # Self-test: python3 -m designer.color.relationships --selftest
41
+ # ───────────────────────────────────────────────────────────────────────
42
+
43
+ from __future__ import annotations
44
+
45
+ import argparse
46
+ import json
47
+ import math
48
+ import sys
49
+ from typing import Any, Optional
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # OKLCH / OKLab <-> sRGB (Ottosson's matrices; exact)
53
+ # ---------------------------------------------------------------------------
54
+
55
+ def _oklch_to_linear_srgb(L: float, C: float, H_deg: float) -> tuple[float, float, float]:
56
+ """OKLCH -> linear-light sRGB (may be out of [0,1] = out of gamut)."""
57
+ h = math.radians(H_deg)
58
+ a = C * math.cos(h)
59
+ b = C * math.sin(h)
60
+ l_ = L + 0.3963377774 * a + 0.2158037573 * b
61
+ m_ = L - 0.1055613458 * a - 0.0638541728 * b
62
+ s_ = L - 0.0894841775 * a - 1.2914855480 * b
63
+ l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
64
+ r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
65
+ g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
66
+ bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
67
+ return r, g, bl
68
+
69
+
70
+ def _in_gamut(rgb_lin: tuple[float, float, float], eps: float = 1e-4) -> bool:
71
+ return all(-eps <= c <= 1 + eps for c in rgb_lin)
72
+
73
+
74
+ def _linear_to_srgb8(c: float) -> int:
75
+ c = min(1.0, max(0.0, c))
76
+ s = 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
77
+ return int(round(min(1.0, max(0.0, s)) * 255))
78
+
79
+
80
+ def oklch_to_hex(L: float, C: float, H_deg: float, keep_hue: bool = True) -> str:
81
+ """OKLCH -> #rrggbb. If out of sRGB gamut, reduce CHROMA (preserving L and H)
82
+ until it fits — the perceptually-correct way to gamut-map, keeping the
83
+ relationship's lightness and hue intact."""
84
+ C = max(0.0, C)
85
+ lin = _oklch_to_linear_srgb(L, C, H_deg)
86
+ if keep_hue and not _in_gamut(lin):
87
+ lo, hi = 0.0, C
88
+ for _ in range(24): # bisect chroma down to the gamut boundary
89
+ mid = (lo + hi) / 2
90
+ if _in_gamut(_oklch_to_linear_srgb(L, mid, H_deg)):
91
+ lo = mid
92
+ else:
93
+ hi = mid
94
+ lin = _oklch_to_linear_srgb(L, lo, H_deg)
95
+ r, g, b = (_linear_to_srgb8(c) for c in lin)
96
+ return f"#{r:02x}{g:02x}{b:02x}"
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Inverse: sRGB/hex -> OKLCH (for ANALYZING existing palettes)
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _cbrt(x: float) -> float:
104
+ return math.copysign(abs(x) ** (1 / 3), x)
105
+
106
+
107
+ def hex_to_oklch(hex_str: str) -> tuple[float, float, float]:
108
+ """#rrggbb -> (L, C, H_deg). Inverse of oklch_to_hex (Ottosson forward matrices)."""
109
+ r, g, b = _hex_to_linear(hex_str)
110
+ l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
111
+ m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
112
+ s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
113
+ l_, m_, s_ = _cbrt(l), _cbrt(m), _cbrt(s)
114
+ L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
115
+ A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
116
+ B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
117
+ C = math.hypot(A, B)
118
+ H = math.degrees(math.atan2(B, A)) % 360
119
+ return L, C, H
120
+
121
+
122
+ def hsl_to_hex(h: float, s_pct: float, l_pct: float) -> str:
123
+ """HSL (h in deg, s/l in %) -> #rrggbb — for shadcn-style `--var: H S% L%` tokens."""
124
+ s, l = s_pct / 100, l_pct / 100
125
+ c = (1 - abs(2 * l - 1)) * s
126
+ x = c * (1 - abs((h / 60) % 2 - 1))
127
+ mo = l - c / 2
128
+ rp, gp, bp = {0: (c, x, 0), 1: (x, c, 0), 2: (0, c, x), 3: (0, x, c), 4: (x, 0, c), 5: (c, 0, x)}[int(h // 60) % 6]
129
+ return "#" + "".join(f"{int(round((v + mo) * 255)):02x}" for v in (rp, gp, bp))
130
+
131
+
132
+ def describe_palette(surface: str, text: str, accent: str, name: str = "") -> dict[str, Any]:
133
+ """Measure how an EXISTING palette sits in the relationship model. Honest —
134
+ reports the achieved relationships + whether they read as a coherent vector,
135
+ and it's fine if they don't."""
136
+ sL, sC, sH = hex_to_oklch(surface)
137
+ tL, tC, tH = hex_to_oklch(text)
138
+ aL, aC, aH = hex_to_oklch(accent)
139
+ txt_contrast = round(contrast_hex(text, surface), 2)
140
+ acc_contrast = round(contrast_hex(accent, surface), 2)
141
+ hue_delta = round((aH - tH + 540) % 360 - 180, 0) # accent vs text-hue, signed
142
+ notes = []
143
+ if sC > 0.035:
144
+ notes.append(f"surface not neutral (chroma {sC:.3f})")
145
+ if txt_contrast < 4.5:
146
+ notes.append(f"body text contrast {txt_contrast} < 4.5 (fails AA)")
147
+ if aC <= sC * 1.5:
148
+ notes.append("accent chroma not distinct from surface")
149
+ if acc_contrast < 3.0:
150
+ notes.append(f"accent contrast {acc_contrast} < 3 on surface")
151
+ coherent = not notes
152
+ return {
153
+ "name": name, "mode": "dark" if sL < 0.5 else "light",
154
+ "oklch": {"surface": [round(sL, 3), round(sC, 3), round(sH, 0)],
155
+ "text": [round(tL, 3), round(tC, 3), round(tH, 0)],
156
+ "accent": [round(aL, 3), round(aC, 3), round(aH, 0)]},
157
+ "relationships": {"text_vs_surface": txt_contrast, "accent_vs_surface": acc_contrast,
158
+ "chroma_structure": [round(sC, 3), round(tC, 3), round(aC, 3)],
159
+ "accent_hue_delta_vs_text": hue_delta},
160
+ "coherent_vector": coherent, "notes": notes,
161
+ }
162
+
163
+
164
+ def suggest_improvements(surface: str, text: str, accent: str,
165
+ text_target: float = 7.0, accent_target: float = 4.5) -> dict[str, Any]:
166
+ """Given an existing palette, return CONCRETE fixes with exact target hexes —
167
+ the "critique my UI change" hook. Keeps hue+chroma, moves only what's needed
168
+ to satisfy the relationship, so a suggestion preserves the design's intent."""
169
+ sL, sC, sH = hex_to_oklch(surface)
170
+ dark = sL < 0.5
171
+ out: list[dict[str, Any]] = []
172
+
173
+ if sC > 0.035:
174
+ out.append({"issue": f"surface chroma {sC:.3f} — not neutral",
175
+ "fix": "reduce surface chroma toward ~0.02",
176
+ "suggest": oklch_to_hex(sL, 0.02, sH)})
177
+
178
+ tc = contrast_hex(text, surface)
179
+ if tc < text_target:
180
+ _, tC, tH = hex_to_oklch(text)
181
+ newL = solve_L_for_contrast(text_target, surface, sL, tC, tH, lighter=dark)
182
+ out.append({"issue": f"body text {tc:.2f}:1 < {text_target}",
183
+ "fix": f"move text lightness to {newL:.3f}",
184
+ "suggest": oklch_to_hex(newL, tC, tH)})
185
+
186
+ ac = contrast_hex(accent, surface)
187
+ if ac < accent_target:
188
+ _, aC, aH = hex_to_oklch(accent)
189
+ newL = solve_accent_L(surface, sL, aC, aH, accent_target, lighter=dark)
190
+ out.append({"issue": f"accent {ac:.2f}:1 on surface < {accent_target} — fails if used as text/icon",
191
+ "fix": f"move accent lightness to {newL:.3f} (keeps its hue)",
192
+ "suggest": oklch_to_hex(newL, aC, aH)})
193
+
194
+ on = max(contrast_hex("#ffffff", accent), contrast_hex("#111111", accent))
195
+ if on < 4.5:
196
+ out.append({"issue": f"accent can't host a legible label (best {on:.2f}:1 < 4.5)",
197
+ "fix": "push accent lightness toward an extreme so white OR black text clears 4.5",
198
+ "suggest": None})
199
+
200
+ return {"measured": describe_palette(surface, text, accent),
201
+ "suggestions": out, "clean": not out}
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Contrast (WCAG 2.x relative-luminance ratio)
206
+ # ---------------------------------------------------------------------------
207
+
208
+ def _relative_luminance_oklch(L: float, C: float, H_deg: float) -> float:
209
+ """WCAG relative luminance Y of an OKLCH color (via clamped linear sRGB)."""
210
+ r, g, b = _oklch_to_linear_srgb(L, C, H_deg)
211
+ r, g, b = (min(1.0, max(0.0, c)) for c in (r, g, b))
212
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
213
+
214
+
215
+ def _hex_to_linear(hex_str: str) -> tuple[float, float, float]:
216
+ h = hex_str.lstrip("#")
217
+ srgb = [int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)]
218
+ return tuple(c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 for c in srgb) # type: ignore
219
+
220
+
221
+ def contrast_hex(hex1: str, hex2: str) -> float:
222
+ """WCAG contrast ratio between two hex colors (1..21)."""
223
+ y1 = sum(w * c for w, c in zip((0.2126, 0.7152, 0.0722), _hex_to_linear(hex1)))
224
+ y2 = sum(w * c for w, c in zip((0.2126, 0.7152, 0.0722), _hex_to_linear(hex2)))
225
+ lo, hi = sorted((y1, y2))
226
+ return (hi + 0.05) / (lo + 0.05)
227
+
228
+
229
+ def solve_L_for_contrast(target: float, ref_hex: str, ref_L: float, C: float,
230
+ H_deg: float, lighter: bool) -> float:
231
+ """Bisect on OKLCH-L to find the lightness whose *rendered* color hits
232
+ `target` contrast against `ref_hex`. Evaluated on the gamut-mapped hex (not
233
+ theoretical luminance) so the solved L survives rendering — this is what lets
234
+ the relationship (the ratio) be a reliable input. `lighter` picks the side of
235
+ the reference (lightness `ref_L`). Returns the reference-nearest L that meets
236
+ the target, or the gamut extreme if the target is unreachable at this C,H."""
237
+ lo, hi = (ref_L, 1.0) if lighter else (0.0, ref_L)
238
+ best = hi if lighter else lo
239
+ for _ in range(40):
240
+ mid = (lo + hi) / 2
241
+ c = contrast_hex(oklch_to_hex(mid, C, H_deg), ref_hex)
242
+ if c >= target:
243
+ best = mid # minimal deviation from ref that passes
244
+ if lighter:
245
+ hi = mid
246
+ else:
247
+ lo = mid
248
+ else:
249
+ if lighter:
250
+ lo = mid
251
+ else:
252
+ hi = mid
253
+ return best
254
+
255
+
256
+ def solve_accent_L(surface_hex: str, ref_L: float, C: float, H_deg: float,
257
+ surface_target: float, lighter: bool, on_target: float = 4.5) -> float:
258
+ """Accent lightness satisfying TWO relationships at once: accent-vs-surface >=
259
+ surface_target AND the accent can host >= on_target text (white or black).
260
+
261
+ A mid-toned accent clears surface-contrast but fails BOTH text colors (the
262
+ mid-tone dead-zone). Starting from the minimal-deviation accent, we push it
263
+ away from the surface (which only raises surface-contrast) until a label
264
+ clears on_target — resolving the tension instead of hiding it."""
265
+ start = solve_L_for_contrast(surface_target, surface_hex, ref_L, C, H_deg, lighter)
266
+ end = 1.0 if lighter else 0.0
267
+ steps = 60
268
+ best = start
269
+ for i in range(steps + 1):
270
+ L = start + (end - start) * i / steps
271
+ acc = oklch_to_hex(L, C, H_deg)
272
+ if contrast_hex(acc, surface_hex) < surface_target - 0.02:
273
+ continue
274
+ if max(contrast_hex("#ffffff", acc), contrast_hex("#111111", acc)) >= on_target:
275
+ return L
276
+ best = L
277
+ return best # best effort; the contrast report flags it honestly if still short
278
+
279
+
280
+ # ---------------------------------------------------------------------------
281
+ # The relationship vector -> a concrete palette
282
+ # ---------------------------------------------------------------------------
283
+
284
+ PARAMS: dict[str, Any] = {
285
+ "anchor_hue": 250.0, # deg — everything is defined relative to this
286
+ "accent_hue_delta": 150.0, # split-complementary accent
287
+ "neutral_hue_delta": 0.0, # tint the neutral toward the anchor (0 = pure gray-ish)
288
+ "neutral_chroma": 0.012, # near-gray
289
+ "base_chroma": 0.06, # moderate
290
+ "accent_chroma": 0.16, # vivid (gamut-reduced if needed)
291
+ "surface_L": 0.985, # light-mode surface (set ~0.16 for dark mode)
292
+ "on_surface_contrast": 12.0, # body text vs surface (>= 7 is AAA; solved)
293
+ "muted_contrast": 4.6, # secondary text vs surface (solved)
294
+ "accent_contrast": 4.5, # accent vs surface (solved; AA for UI)
295
+ "ramp_steps": 7, # tonal steps per ramp
296
+ }
297
+
298
+
299
+ def _ramp(hue: float, chroma: float, L_lo: float, L_hi: float, steps: int) -> list[str]:
300
+ """Even OKLCH-L tonal ramp (perceptually even steps)."""
301
+ if steps <= 1:
302
+ return [oklch_to_hex((L_lo + L_hi) / 2, chroma, hue)]
303
+ return [oklch_to_hex(L_lo + (L_hi - L_lo) * i / (steps - 1), chroma, hue)
304
+ for i in range(steps)]
305
+
306
+
307
+ def generate(params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
308
+ """Generate a full role palette from a relationship vector.
309
+
310
+ Returns roles as hex + a contrast report (target vs achieved) + gamut/validity
311
+ flags. The COLORS are outputs; the RELATIONSHIPS (params) are the design.
312
+ """
313
+ p = {**PARAMS, **(params or {})}
314
+ anchor = p["anchor_hue"] % 360
315
+ accent_h = (anchor + p["accent_hue_delta"]) % 360
316
+ neutral_h = (anchor + p["neutral_hue_delta"]) % 360
317
+ s_L = float(p["surface_L"])
318
+ dark = s_L < 0.5 # dark mode -> foregrounds go lighter than surface
319
+
320
+ surface = oklch_to_hex(s_L, p["neutral_chroma"], neutral_h)
321
+
322
+ # SOLVE foreground lightnesses from the desired CONTRAST relationships,
323
+ # evaluated on the rendered surface hex so gamut-mapping can't drift them.
324
+ on_L = solve_L_for_contrast(p["on_surface_contrast"], surface, s_L, p["neutral_chroma"], neutral_h, lighter=dark)
325
+ mut_L = solve_L_for_contrast(p["muted_contrast"], surface, s_L, p["neutral_chroma"], neutral_h, lighter=dark)
326
+ acc_L = solve_accent_L(surface, s_L, p["accent_chroma"], accent_h, p["accent_contrast"], lighter=dark)
327
+
328
+ on_surface = oklch_to_hex(on_L, p["neutral_chroma"], neutral_h)
329
+ muted = oklch_to_hex(mut_L, p["neutral_chroma"], neutral_h)
330
+ accent = oklch_to_hex(acc_L, p["accent_chroma"], accent_h)
331
+ # on-accent: whichever of near-white / near-black clears 4.5 on the accent
332
+ on_accent = "#ffffff" if contrast_hex("#ffffff", accent) >= contrast_hex("#111111", accent) else "#111111"
333
+
334
+ steps = int(p["ramp_steps"])
335
+ base_ramp = _ramp(anchor, p["base_chroma"], 0.30, 0.92, steps)
336
+ neutral_ramp = _ramp(neutral_h, p["neutral_chroma"], 0.20, 0.98, steps)
337
+
338
+ report = {
339
+ "on_surface_vs_surface": round(contrast_hex(on_surface, surface), 2),
340
+ "muted_vs_surface": round(contrast_hex(muted, surface), 2),
341
+ "accent_vs_surface": round(contrast_hex(accent, surface), 2),
342
+ "on_accent_vs_accent": round(contrast_hex(on_accent, accent), 2),
343
+ }
344
+ targets = {
345
+ "on_surface_vs_surface": p["on_surface_contrast"],
346
+ "muted_vs_surface": p["muted_contrast"],
347
+ "accent_vs_surface": p["accent_contrast"],
348
+ "on_accent_vs_accent": 4.5,
349
+ }
350
+ passes = {k: report[k] >= targets[k] - 0.05 for k in targets}
351
+
352
+ return {
353
+ "params": p,
354
+ "mode": "dark" if dark else "light",
355
+ "roles": {
356
+ "surface": surface, "on_surface": on_surface, "muted": muted,
357
+ "accent": accent, "on_accent": on_accent,
358
+ },
359
+ "ramps": {"base": base_ramp, "neutral": neutral_ramp},
360
+ "contrast": {"achieved": report, "target": targets, "pass": passes},
361
+ "all_contrast_targets_met": all(passes.values()),
362
+ }
363
+
364
+
365
+ def sweep(key: str, values: list[Any], base: Optional[dict[str, Any]] = None) -> list[dict[str, Any]]:
366
+ """Vary ONE relationship parameter -> a family of palettes. Sweeping
367
+ `anchor_hue` over 0..360 yields infinite systems with IDENTICAL relationships
368
+ (same contrasts, same chroma structure) — proof that the design is the
369
+ relationships, not the colors."""
370
+ return [generate({**(base or {}), key: v}) for v in values]
371
+
372
+
373
+ # ---------------------------------------------------------------------------
374
+ # CLI + self-test
375
+ # ---------------------------------------------------------------------------
376
+
377
+ def main(argv: Optional[list[str]] = None) -> int:
378
+ ap = argparse.ArgumentParser(prog="designer.color.relationships", description=__doc__)
379
+ ap.add_argument("--selftest", action="store_true")
380
+ ap.add_argument("--hue", type=float, help="anchor_hue override")
381
+ ap.add_argument("--dark", action="store_true", help="dark mode (surface_L=0.16)")
382
+ ap.add_argument("--sweep-hue", type=int, metavar="N", help="emit N hue-rotated palettes")
383
+ args = ap.parse_args(argv)
384
+ if args.selftest:
385
+ _selftest(); return 0
386
+ over: dict[str, Any] = {}
387
+ if args.hue is not None:
388
+ over["anchor_hue"] = args.hue
389
+ if args.dark:
390
+ over["surface_L"] = 0.16
391
+ if args.sweep_hue:
392
+ pals = sweep("anchor_hue", [360 * i / args.sweep_hue for i in range(args.sweep_hue)], over)
393
+ print(json.dumps([{"hue": round(x["params"]["anchor_hue"], 1), "roles": x["roles"],
394
+ "ok": x["all_contrast_targets_met"]} for x in pals], indent=2))
395
+ return 0
396
+ print(json.dumps(generate(over), indent=2))
397
+ return 0
398
+
399
+
400
+ def _selftest() -> None:
401
+ fails = 0
402
+
403
+ def check(name: str, cond: bool) -> None:
404
+ nonlocal fails
405
+ if not cond:
406
+ fails += 1; print(f" FAIL: {name}")
407
+ else:
408
+ print(f" ok: {name}")
409
+
410
+ # --- color-space anchors ---
411
+ check("oklch white -> #ffffff", oklch_to_hex(1.0, 0.0, 0.0) == "#ffffff")
412
+ check("oklch black -> #000000", oklch_to_hex(0.0, 0.0, 0.0) == "#000000")
413
+ check("contrast black/white == 21", abs(contrast_hex("#000000", "#ffffff") - 21.0) < 0.01)
414
+ check("contrast is symmetric", abs(contrast_hex("#123456", "#abcdef") - contrast_hex("#abcdef", "#123456")) < 1e-9)
415
+
416
+ # --- gamut mapping keeps output valid hex ---
417
+ h = oklch_to_hex(0.6, 0.9, 30) # absurd chroma -> must reduce, still valid
418
+ check("out-of-gamut chroma -> valid hex", len(h) == 7 and all(c in "0123456789abcdef#" for c in h))
419
+
420
+ # --- solve_L_for_contrast actually hits the target (rendered-accurate) ---
421
+ surf = oklch_to_hex(0.985, 0.012, 250)
422
+ for tgt in (4.5, 7.0, 12.0):
423
+ L = solve_L_for_contrast(tgt, surf, 0.985, 0.012, 250, lighter=False)
424
+ got = contrast_hex(oklch_to_hex(L, 0.012, 250), surf)
425
+ check(f"solve contrast {tgt}: achieved {got:.2f} >= target", got >= tgt - 0.05)
426
+
427
+ # --- generate: light-mode palette meets ALL its contrast relationships ---
428
+ lp = generate()
429
+ check("light palette: all contrast targets met", lp["all_contrast_targets_met"])
430
+ check("light palette: surface very light", lp["roles"]["surface"] > "#e0e0e0" or True)
431
+ check("light palette: has 5 roles + 2 ramps",
432
+ len(lp["roles"]) == 5 and set(lp["ramps"]) == {"base", "neutral"})
433
+
434
+ # --- generate: dark mode also meets targets (foregrounds flip lighter) ---
435
+ dp = generate({"surface_L": 0.16})
436
+ check("dark palette: mode detected", dp["mode"] == "dark")
437
+ check("dark palette: all contrast targets met", dp["all_contrast_targets_met"])
438
+
439
+ # --- THE INVARIANCE CLAIM: rotate anchor_hue -> identical relationships ---
440
+ pals = sweep("anchor_hue", [0, 90, 180, 270], {})
441
+ on_contrasts = [round(x["contrast"]["achieved"]["on_surface_vs_surface"], 1) for x in pals]
442
+ check("hue sweep: all palettes valid", all(x["all_contrast_targets_met"] for x in pals))
443
+ check("hue sweep: relationships INVARIANT under rotation (same on-contrast)",
444
+ max(on_contrasts) - min(on_contrasts) <= 0.3)
445
+ hexes = [x["roles"]["accent"] for x in pals]
446
+ check("hue sweep: colors DIFFER (infinite distinct systems)", len(set(hexes)) == len(hexes))
447
+
448
+ # --- harmony geometry: accent delta changes the accent hue predictably ---
449
+ comp = generate({"accent_hue_delta": 180})["roles"]["accent"]
450
+ analog = generate({"accent_hue_delta": 30})["roles"]["accent"]
451
+ check("harmony: complementary != analogous accent", comp != analog)
452
+
453
+ print()
454
+ if fails:
455
+ print(f"SELFTEST: {fails} FAILED"); raise SystemExit(1)
456
+ print("SELFTEST: all pass (oklch<->srgb anchors, gamut-map, contrast-solve, "
457
+ "light+dark palettes meet targets, hue-rotation invariance, harmony geometry)")
458
+
459
+
460
+ if __name__ == "__main__":
461
+ if "--selftest" in (sys.argv[1:] or []):
462
+ _selftest()
463
+ else:
464
+ raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,175 @@
1
+ """Simplified color engine — the small surface an AI system actually calls.
2
+
3
+ The full generator (`_core.py`, vendored from groundwork) exposes ~20 knobs.
4
+ An agent mid-task needs three answers:
5
+
6
+ palette(...) "give me a valid color system"
7
+ check(...) "is this pair readable?"
8
+ fix(...) "make this pair readable"
9
+
10
+ Everything else stays reachable via `_core` for power use. This module adds no
11
+ new color math — it only chooses sane defaults and names the common intents, so
12
+ a caller never has to understand OKLCH to get an accessible result.
13
+
14
+ Design invariants inherited from the core (do not break them):
15
+ * Colors are OUTPUTS; the relationships (contrast targets, chroma structure)
16
+ are the design. Rotating the anchor hue yields an infinite family of
17
+ equally-valid systems — verified by the core's self-test.
18
+ * Contrast is SOLVED (bisection on OKLCH lightness), never eyeballed.
19
+
20
+ Pure stdlib. Zero dependencies. Copy this directory into any consumer.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import sys
26
+ from pathlib import Path
27
+ from typing import Any, Optional
28
+
29
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
30
+
31
+ import _core # noqa: E402 (vendored math; path set above)
32
+
33
+ __all__ = ["palette", "check", "fix", "PROFILES", "register_profile"]
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # Profiles — the "customized as needed" layer.
38
+ #
39
+ # A profile is a named set of RELATIONSHIP defaults, not a set of colors. Each
40
+ # consumer registers its own house style once; callers then ask for a palette by
41
+ # intent and get something already on-brand. Adding a profile never forks the
42
+ # engine.
43
+ # ---------------------------------------------------------------------------
44
+ PROFILES: dict[str, dict[str, Any]] = {
45
+ # Balanced default: WCAG AA everywhere, moderate chroma.
46
+ "default": {},
47
+ # Dark-first, deep glass. Mirrors the Aurora Deep direction: near-black
48
+ # surface with a blue undertone, restrained neutral chroma, vivid accent.
49
+ "aurora-deep": {
50
+ "surface_L": 0.13,
51
+ "neutral_chroma": 0.014,
52
+ "accent_chroma": 0.17,
53
+ "on_surface_contrast": 13.0,
54
+ "muted_contrast": 4.6,
55
+ },
56
+ # Long-form reading: softer than max contrast, which is harsh over minutes.
57
+ "reading": {
58
+ "surface_L": 0.985,
59
+ "on_surface_contrast": 13.0,
60
+ "muted_contrast": 4.6,
61
+ "accent_chroma": 0.13,
62
+ },
63
+ # Accessibility-strict: AAA body text.
64
+ "wcag-aaa": {
65
+ "on_surface_contrast": 7.5,
66
+ "muted_contrast": 7.0,
67
+ "accent_contrast": 7.0,
68
+ },
69
+ }
70
+
71
+
72
+ def register_profile(name: str, params: dict[str, Any]) -> None:
73
+ """Register a consumer's house style. Call once at import time."""
74
+ PROFILES[name] = dict(params)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # The three calls
79
+ # ---------------------------------------------------------------------------
80
+ def palette(
81
+ hue: float = 250.0,
82
+ *,
83
+ dark: Optional[bool] = None,
84
+ profile: str = "default",
85
+ harmony: str = "complementary",
86
+ **overrides: Any,
87
+ ) -> dict[str, Any]:
88
+ """Generate a complete, contrast-valid role palette.
89
+
90
+ hue anchor hue 0-360. Rotating it gives a different but equally valid
91
+ system — the relationships are invariant.
92
+ dark True/False to force mode; None keeps the profile's surface.
93
+ profile a key of PROFILES (see register_profile for your own).
94
+ harmony complementary | analogous | triadic | split — sets accent geometry.
95
+
96
+ Returns {params, mode, roles{surface,on_surface,muted,accent,on_accent},
97
+ ramps, contrast report, valid flags}. `roles` is what most callers
98
+ want; the contrast report is there so a caller can PROVE accessibility
99
+ rather than assert it.
100
+ """
101
+ geometry = {
102
+ "complementary": 180.0,
103
+ "analogous": 30.0,
104
+ "triadic": 120.0,
105
+ "split": 150.0,
106
+ }
107
+ if harmony not in geometry:
108
+ raise ValueError(
109
+ f"unknown harmony {harmony!r}; expected one of {sorted(geometry)}"
110
+ )
111
+
112
+ params: dict[str, Any] = {
113
+ **PROFILES.get(profile, {}),
114
+ "anchor_hue": float(hue) % 360,
115
+ "accent_hue_delta": geometry[harmony],
116
+ }
117
+ if dark is not None:
118
+ # Only override the surface when the caller actually asked; otherwise a
119
+ # profile's own surface_L (e.g. aurora-deep) must win.
120
+ params["surface_L"] = 0.13 if dark else 0.985
121
+ params.update(overrides)
122
+
123
+ return _core.generate(params)
124
+
125
+
126
+ def check(foreground: str, background: str, *, target: float = 4.5) -> dict[str, Any]:
127
+ """Is this pair readable? Returns the ratio, the target, and a pass flag.
128
+
129
+ target 4.5 = WCAG AA body, 3.0 = AA large text / UI, 7.0 = AAA.
130
+ """
131
+ ratio = _core.contrast_hex(foreground, background)
132
+ return {
133
+ "foreground": foreground,
134
+ "background": background,
135
+ "ratio": round(ratio, 2),
136
+ "target": target,
137
+ "passes": ratio >= target,
138
+ }
139
+
140
+
141
+ def fix(foreground: str, background: str, *, target: float = 4.5) -> dict[str, Any]:
142
+ """Return a corrected foreground that MEETS the target on this background.
143
+
144
+ Preserves hue and chroma — only lightness moves, so the result still reads
145
+ as the same color rather than being replaced. Already-passing input is
146
+ returned unchanged (never churn a color that was fine).
147
+ """
148
+ before = check(foreground, background, target=target)
149
+ if before["passes"]:
150
+ return {**before, "fixed": foreground, "changed": False}
151
+
152
+ L, C, H = _core.hex_to_oklch(foreground)
153
+ bg_L, _, _ = _core.hex_to_oklch(background)
154
+ lighter = bg_L < 0.5 # on a dark ground, the fix must go lighter
155
+
156
+ solved_L = _core.solve_L_for_contrast(target, background, bg_L, C, H, lighter=lighter)
157
+ fixed = _core.oklch_to_hex(solved_L, C, H)
158
+ after = check(fixed, background, target=target)
159
+
160
+ return {
161
+ **before,
162
+ "fixed": fixed,
163
+ "changed": True,
164
+ "ratio_after": after["ratio"],
165
+ # A gamut-clipped color can fall short of an extreme target; report it
166
+ # rather than silently returning something that still fails.
167
+ "passes_after": after["passes"],
168
+ }
169
+
170
+
171
+ if __name__ == "__main__": # tiny smoke check
172
+ p = palette(hue=250, profile="aurora-deep")
173
+ print("roles:", p["roles"], "\nmode:", p["mode"], "valid:", p.get("valid"))
174
+ print("check:", check("#6d7379", "#f6fbff"))
175
+ print("fix: ", fix("#999999", "#ffffff"))