@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.
- package/.agents/plugins/marketplace.json +2 -2
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/build-loop-surface.mdc +12 -11
- package/.cursor/rules/skill-index.mdc +33 -0
- package/AGENTS.md +213 -34
- package/README.md +99 -31
- package/agents/advisor.md +4 -4
- package/agents/alignment-checker.md +2 -2
- package/agents/architecture-scout.md +4 -4
- package/agents/build-orchestrator.md +38 -36
- package/agents/database-assessor.md +11 -5
- package/agents/design-contract-specialist.md +8 -8
- package/agents/fact-checker.md +13 -3
- package/agents/fix-critique.md +2 -2
- package/agents/independent-auditor.md +60 -7
- package/agents/leak-scanner.md +82 -0
- package/agents/overfitting-reviewer.md +2 -2
- package/agents/plan-critic.md +1 -1
- package/agents/promotion-reviewer.md +5 -5
- package/agents/retrospective-synthesizer.md +138 -35
- package/agents/scope-auditor.md +82 -11
- package/agents/security-reviewer.md +56 -2
- package/agents/self-improvement-architect.md +17 -3
- package/agents/transcript-pattern-miner.md +5 -5
- package/agents/ui-validator.md +1 -1
- package/bin/build-loop-debugger.js +143 -0
- package/bin/build-loop-install.js +1 -4
- package/bin/build-loop-load-probe.js +345 -0
- package/codex-skills/build-loop/SKILL.md +28 -6
- package/commands/feedback.md +37 -0
- package/dist/src/interactive-verifier.d.ts +1 -14
- package/dist/src/interactive-verifier.d.ts.map +1 -1
- package/dist/src/interactive-verifier.js +6 -113
- package/dist/src/interactive-verifier.js.map +1 -1
- package/dist/src/quality.d.ts +5 -0
- package/dist/src/quality.d.ts.map +1 -0
- package/dist/src/quality.js +81 -0
- package/dist/src/quality.js.map +1 -0
- package/dist/src/storage.d.ts.map +1 -1
- package/dist/src/storage.js +37 -3
- package/dist/src/storage.js.map +1 -1
- package/docs/agent-surface-policy.md +35 -31
- package/docs/memory-setup.md +19 -0
- package/hooks/git/pre-push +65 -4
- package/hooks/hooks.json +95 -38
- package/hooks/pre-commit +20 -1
- package/hooks/pre-edit-rally-point.sh +10 -3
- package/hooks/session-start-codex-hook-trust.sh +30 -0
- package/hooks/session-start-git-hooks.sh +3 -1
- package/hooks/session-start-rally-point.sh +52 -4
- package/hooks/session-start-worktree-gc.sh +47 -94
- package/hooks/stop-transcript-sweep.sh +173 -0
- package/hooks/test_closeout.sh +14 -2
- package/package.json +8 -7
- package/scripts/README.md +1 -1
- package/scripts/_paths.py +65 -0
- package/scripts/groundwork_exchange.py +1012 -0
- package/scripts/install_memory.py +33 -1
- package/scripts/lessons_index/ingest.py +13 -2
- package/scripts/lessons_index/query.py +36 -13
- package/scripts/memory_context/__init__.py +108 -14
- package/scripts/memory_graph/__init__.py +5 -1
- package/scripts/project_resolver.py +42 -36
- package/scripts/sync_plugin_cache.py +37 -2
- package/skills/agent-rally-point/SKILL.md +46 -0
- package/skills/api-registry-bridge/SKILL.md +1 -1
- package/skills/architecture/dead/SKILL.md +1 -1
- package/skills/architecture/impact/SKILL.md +1 -1
- package/skills/architecture/review/SKILL.md +1 -1
- package/skills/architecture/rules/SKILL.md +3 -3
- package/skills/architecture/scan/SKILL.md +1 -1
- package/skills/architecture/trace/SKILL.md +1 -1
- package/skills/attribution-standard/SKILL.md +6 -6
- package/skills/auto-decision-capture/SKILL.md +31 -2
- package/skills/auto-finding-capture/SKILL.md +28 -1
- package/skills/build-loop/SKILL.md +131 -23
- package/skills/build-loop/fallbacks.md +16 -21
- package/skills/build-loop/phases/ui-validation.md +2 -2
- package/skills/build-loop/references/advisor-dispatch-ladder.md +1 -1
- package/skills/build-loop/references/apple-native-planning.md +1 -1
- package/skills/build-loop/references/autonomous-and-per-commit-modes.md +11 -5
- package/skills/build-loop/references/autonomy-dashboard.md +115 -0
- package/skills/build-loop/references/capability-routing.md +24 -2
- package/skills/build-loop/references/coordination.md +24 -6
- package/skills/build-loop/references/experiment-results-template.md +15 -3
- package/skills/build-loop/references/leadership.md +1 -1
- package/skills/build-loop/references/memory.md +14 -3
- package/skills/build-loop/references/modular-systems-pack.md +8 -0
- package/skills/build-loop/references/output-style.md +86 -0
- package/skills/build-loop/references/phase-1-assess.md +102 -2
- package/skills/build-loop/references/phase-2-plan.md +9 -1
- package/skills/build-loop/references/phase-3-execute.md +5 -2
- package/skills/build-loop/references/phase-4-review.md +85 -8
- package/skills/build-loop/references/phase-5-iterate.md +76 -8
- package/skills/build-loop/references/phase-6-learn.md +10 -17
- package/skills/build-loop/references/privileged-request-broker.md +254 -0
- package/skills/build-loop/references/resource-aware-execution.md +183 -0
- package/skills/build-loop/references/self-recursive-dev.md +2 -2
- package/skills/build-loop/references/status-output-format.md +207 -0
- package/skills/build-loop/references/verify-dispatch.md +56 -2
- package/skills/building-with-deepagents/SKILL.md +1 -1
- package/skills/claim-scope/SKILL.md +185 -0
- package/skills/color-engine/SKILL.md +103 -0
- package/skills/color-engine/_core.py +464 -0
- package/skills/color-engine/color_engine.py +175 -0
- package/skills/cost-rca/SKILL.md +61 -0
- package/skills/data-plane-worktrees/SKILL.md +139 -0
- package/skills/data-plane-worktrees/agents/openai.yaml +4 -0
- package/skills/database-practice/SKILL.md +200 -0
- package/skills/database-practice/references/diagnostic-queries.sql +126 -0
- package/skills/database-practice/references/vector-and-graph-tuning.md +208 -0
- package/skills/database-practice/scripts/db_table_map.py +1244 -0
- package/skills/database-practice/scripts/test_db_table_map.py +514 -0
- package/skills/debug-loop/SKILL.md +36 -6
- package/skills/debugging-memory/SKILL.md +32 -430
- package/skills/debugging-memory/references/pattern-extraction.md +4 -4
- package/skills/debugging-memory/references/search.md +32 -120
- package/skills/debugging-memory/references/store.md +32 -126
- package/skills/debugging-memory/references/subagent-integration.md +1 -1
- package/skills/decision-queue/SKILL.md +251 -0
- package/skills/decision-queue/assets/template.html +1242 -0
- package/skills/decision-queue/references/example-large-queue-batching.md +164 -0
- package/skills/decision-queue/scripts/regen_template_constants.py +160 -0
- package/skills/defenseclaw-bridge/SKILL.md +2 -2
- package/skills/defenseclaw-bridge/references/dc-config-mapping.md +2 -9
- package/skills/drain-proposals/SKILL.md +53 -0
- package/skills/focused-loop-builder/SKILL.md +31 -0
- package/skills/focused-loop-builder/references/spec-format.md +27 -0
- package/skills/handoff/SKILL.md +169 -8
- package/skills/ibr-bridge/SKILL.md +4 -1
- package/skills/knowledge/SKILL.md +26 -14
- package/skills/knowledge/references/review-mode.md +2 -3
- package/skills/knowledge/templates/madr-minimal.md +1 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/model-bakeoff/SKILL.md +48 -10
- package/skills/model-tiering/SKILL.md +92 -31
- package/skills/native-ax-driver/SKILL.md +38 -5
- package/skills/native-ax-driver/scripts/native_driver.py +278 -22
- package/skills/native-ax-driver/scripts/test_native_driver.py +227 -0
- package/skills/optimize/SKILL.md +1 -1
- package/skills/plugin-builder/SKILL.md +48 -1
- package/skills/plugin-builder/references/build-loop-phase-guidance.md +3 -4
- package/skills/plugin-builder/references/distribution.md +13 -2
- package/skills/plugin-builder/references/plugin-hygiene-lessons.md +2 -2
- package/skills/plugin-tests/SKILL.md +2 -2
- package/skills/recursive-retrospective/SKILL.md +1 -1
- package/skills/repo-closeout/SKILL.md +17 -0
- package/skills/repo-closeout/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/SKILL.md +179 -0
- package/skills/repo-maintenance/agents/openai.yaml +4 -0
- package/skills/repo-maintenance/references/pre-public-hygiene.md +134 -0
- package/skills/repo-maintenance/references/repository-taxonomy.md +161 -0
- package/skills/repo-maintenance/references/safety-protocol.md +106 -0
- package/skills/repo-maintenance/references/stack-profiles.md +138 -0
- package/skills/repo-maintenance/scripts/audit_repo_maintenance.py +1198 -0
- package/skills/repo-maintenance/scripts/test_audit_repo_maintenance.py +506 -0
- package/skills/repository-intelligence/SKILL.md +189 -0
- package/skills/repository-intelligence/agents/openai.yaml +4 -0
- package/skills/repository-intelligence/references/assessment-rubric.md +88 -0
- package/skills/repository-intelligence/scripts/repository_inventory.py +347 -0
- package/skills/research/SKILL.md +12 -2
- package/skills/root-cause-analysis/SKILL.md +1 -1
- package/skills/runtime-parity-verification/SKILL.md +36 -1
- package/skills/security-methodology/SKILL.md +23 -10
- package/skills/security-methodology/references/agentic-handoff-templates.md +220 -0
- package/skills/security-methodology/references/cross-source-matrix.md +1 -1
- package/skills/security-methodology/references/owasp-agentic-top-10.md +1 -1
- package/skills/security-scan/SKILL.md +55 -15
- package/skills/self-improve/SKILL.md +70 -50
- package/skills/silent-assumptions/SKILL.md +341 -0
- package/skills/silent-assumptions/references/elicitation-detectors.md +342 -0
- package/skills/spec-writing/SKILL.md +128 -24
- package/skills/spec-writing/scripts/check_checklist.py +114 -15
- package/skills/ui-design/SKILL.md +6 -4
- package/skills/ui-design/references/color-engine.md +132 -0
- package/skills/ui-design/references/design-preferences-from-owned-apps.md +8 -8
- package/skills/ui-design/references/ui-guidance-sources.md +1 -1
- package/skills/ui-design/references/universal-design-principles.alt.md +2 -2
- package/plugin-artifacts/codex/.codex-plugin/plugin.json +0 -41
- package/plugin-artifacts/codex/AGENTS.md +0 -560
- package/plugin-artifacts/codex/BUILD-ARTIFACT.md +0 -5
- package/plugin-artifacts/codex/LICENSE +0 -202
- package/plugin-artifacts/codex/README.md +0 -313
- package/plugin-artifacts/codex/assets/build-loop-plugin-icon.png +0 -0
- package/plugin-artifacts/codex/docs/agent-surface-policy.md +0 -63
- package/plugin-artifacts/codex/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/references/agent-role-taxonomy.md +0 -135
- package/plugin-artifacts/codex/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/references/autonomy-config.md +0 -231
- package/plugin-artifacts/codex/references/backlog-system.md +0 -285
- package/plugin-artifacts/codex/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/references/coordination-file-template.md +0 -181
- package/plugin-artifacts/codex/references/coordination-rules.md +0 -552
- package/plugin-artifacts/codex/references/dogfood-reload-checkpoint.md +0 -112
- package/plugin-artifacts/codex/references/halt-and-ask-protocol.md +0 -102
- package/plugin-artifacts/codex/references/implementer-envelope-schema.md +0 -302
- package/plugin-artifacts/codex/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/references/leadership.md +0 -72
- package/plugin-artifacts/codex/references/memory-systems.md +0 -261
- package/plugin-artifacts/codex/references/memory.md +0 -313
- package/plugin-artifacts/codex/references/model-tier-mapping.md +0 -296
- package/plugin-artifacts/codex/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/references/research-trigger-policy.md +0 -140
- package/plugin-artifacts/codex/references/runtime-smoke-triggers.md +0 -42
- package/plugin-artifacts/codex/references/self-review.md +0 -234
- package/plugin-artifacts/codex/references/single-writer-commit-protocol.md +0 -90
- package/plugin-artifacts/codex/references/task-capture-policy.md +0 -68
- package/plugin-artifacts/codex/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/references/ui-spotcheck-protocol.md +0 -65
- package/plugin-artifacts/codex/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/SKILL.md +0 -381
- package/plugin-artifacts/codex/skills/build-loop/detect-plugins.mjs +0 -82
- package/plugin-artifacts/codex/skills/build-loop/eval-guide.md +0 -65
- package/plugin-artifacts/codex/skills/build-loop/fallbacks.md +0 -549
- package/plugin-artifacts/codex/skills/build-loop/phases/fact-check.md +0 -42
- package/plugin-artifacts/codex/skills/build-loop/phases/ui-validation.md +0 -267
- package/plugin-artifacts/codex/skills/build-loop/references/advisor-dispatch-ladder.md +0 -62
- package/plugin-artifacts/codex/skills/build-loop/references/apple-native-planning.md +0 -439
- package/plugin-artifacts/codex/skills/build-loop/references/autonomous-and-per-commit-modes.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/capability-routing.md +0 -231
- package/plugin-artifacts/codex/skills/build-loop/references/codex-subagents.md +0 -106
- package/plugin-artifacts/codex/skills/build-loop/references/coordination.md +0 -161
- package/plugin-artifacts/codex/skills/build-loop/references/correction-aware-capture.md +0 -177
- package/plugin-artifacts/codex/skills/build-loop/references/experiment-results-template.md +0 -101
- package/plugin-artifacts/codex/skills/build-loop/references/independent-auditor.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/intent-capability-pack.md +0 -257
- package/plugin-artifacts/codex/skills/build-loop/references/intent-exploration-prompts.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/leadership.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/memory.md +0 -313
- package/plugin-artifacts/codex/skills/build-loop/references/modular-systems-pack.md +0 -96
- package/plugin-artifacts/codex/skills/build-loop/references/output-style.md +0 -222
- package/plugin-artifacts/codex/skills/build-loop/references/pay-it-forward-arch.md +0 -98
- package/plugin-artifacts/codex/skills/build-loop/references/phase-1-assess.md +0 -249
- package/plugin-artifacts/codex/skills/build-loop/references/phase-2-plan.md +0 -86
- package/plugin-artifacts/codex/skills/build-loop/references/phase-3-execute.md +0 -49
- package/plugin-artifacts/codex/skills/build-loop/references/phase-4-review.md +0 -341
- package/plugin-artifacts/codex/skills/build-loop/references/phase-5-iterate.md +0 -72
- package/plugin-artifacts/codex/skills/build-loop/references/phase-6-learn.md +0 -58
- package/plugin-artifacts/codex/skills/build-loop/references/recent-design-structures.md +0 -274
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/ASSESSMENT.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/STANDALONE_TEST_RUN.md +0 -149
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/01-simple-bugfix.md +0 -32
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/02-ui-build-with-iteration.md +0 -48
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/03-multi-failure-escalation.md +0 -60
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/04-ui-build-ibr-absent.md +0 -51
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/05-refactor-navgator-absent.md +0 -71
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/scenarios/06-recurring-bug-debugger-absent.md +0 -52
- package/plugin-artifacts/codex/skills/build-loop/references/refactor-history/trace-comparison.md +0 -202
- package/plugin-artifacts/codex/skills/build-loop/references/self-recursive-dev.md +0 -77
- package/plugin-artifacts/codex/skills/build-loop/references/self-review.md +0 -234
- package/plugin-artifacts/codex/skills/build-loop/references/ui-io-contract.md +0 -116
- package/plugin-artifacts/codex/skills/build-loop/references/verify-dispatch.md +0 -85
- package/plugin-artifacts/codex/skills/build-loop/scanners/audit-design-rules.mjs +0 -476
- package/plugin-artifacts/codex/skills/build-loop/scanners/require-visual-evidence.mjs +0 -239
- package/plugin-artifacts/codex/skills/build-loop/templates/backlog-item.md +0 -35
- package/plugin-artifacts/codex/skills/build-loop/templates/codex-worker-prompt.md +0 -100
- package/plugin-artifacts/codex/skills/build-loop/templates/ui-subagent-prompt.md +0 -179
- package/plugin-artifacts/codex/skills/build-loop/templates/ux-fix-plan.md +0 -40
- 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"))
|