Files
portal/liderra_v8_handoff/scripts/palette_14.py
T
Дмитрий 887abf444e rebrand(v8.5→Лидерра): дизайн-handoff Платона v8 Forest + Лидпоток→Лидерра
Получен handoff-пакет liderra_v8_handoff/ от дизайнера Платона
(kpd9363@gmail.com) от 07.05.2026 — v8 Forest. Заказчик 08.05 решил
применить только в части дизайна, имени, логотипа. Функционал, состав
страниц и правила (CTO-11, click-wrap, SSO break-glass, 14 статусов
воронки) — без изменений (источник — ТЗ v8.5/schema v8.5).

Что сделано:

- Массовая замена Лидпоток→Лидерра (с учётом падежей: Лидерры/Лидерре)
  в 33 файлах (449 вхождений) — все .md/.sql/.json/.toml/.yml/.txt/.html,
  кроме исторических упоминаний внутри liderra_v8_handoff/
- Удалён docs/brandbook.md v1.1 — заменён на BRANDBOOK_v2.md из handoff
- Скопированы 13 концептов liderra_v8_handoff/concepts/v8_*.html в
  web/v8/. Удалены старые web/01-login.html, 02-dashboard.html,
  03-deals.html, index.html (палитра v1.1 deprecated)
- CLAUDE.md v1.0→v1.1: §0 (BRANDBOOK_v2 + DEVELOPER_HANDOFF в источниках),
  §2 (палитра Forest, Inter+JBM, Lucide), §5 п.6 (anti-pattern Inter
  снят — в Forest Inter наш основной шрифт), §6 (13 концептов в web/v8/)
- Реестр Открытые_вопросы_v8_3.md v1.12→v1.13: добавлена запись о
  ребрендинге + 4 точечных расхождений handoff vs ТЗ (статусы воронки,
  click-wrap чекбоксы, SSO fallback, axe violations)
- package.json/package-lock.json: name lidpotok→liderra

4 расхождения handoff vs ТЗ (НЕ применены, источник истины — ТЗ/schema):

1. 14 «обобщённых» статусов в BRANDBOOK_v2 §3.6 ≠ 14 slug'ов в
   schema.sql:2076 (совпадает 2 из 14: «Переговоры», «Оплачено»).
   Источник — schema/ТЗ §6.4 (реселлерская модель из аудита crm.bp-gr.ru,
   6 системных + 8 настраиваемых статусов).
2. 3-й click-wrap в v8_login.html («маркетинг-опционально») ≠ ТЗ §1.5/§4.1
   («согласие на ПДн», обязательное, OPEN-Ж-3).
3. SSO в v8_admin.html («локальный 2FA fallback») ≠ ТЗ OPEN-И-13
   (break-glass super_admin, локальный 2FA выключен).
4. Заявление «axe-core 4.10.2 — 0 violations» в README handoff — локально
   Pa11y 9.1.1 + axe нашёл 81 violation на 10/13 HTML (преимущественно
   color-contrast на декоративных separator'ах с --ink-disabled).
   Чисто: settings/errors/palette_options.

Что НЕ включено в коммит:
- лендинг/TZ_landing_v1_0.md — untracked, не моя работа в этой сессии
- .tmp/ — gitignored

Что осталось (для следующих сессий):
- Возможное переименование GitHub-репо CoralMinister/lidpotok → liderra
  (отдельное решение заказчика)
- Опционально: обратная связь Платону по 4 расхождениям handoff vs ТЗ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 07:11:58 +03:00

195 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Palette for 14 funnel statuses for Лидпоток / TBD.
Goals:
1. 14 distinct hues evenly distributed in OKLCH space
2. ΔE2000 between every adjacent pair >= 10 (perceptually distinct)
3. Two variants per hue:
- "tint" — chip background, light: must give >=4.5:1 against ink #0B1320 for 12px text
- "solid" — strong fill, used for status dots: must give >=3:1 against cream bg #F4EFE5
4. Colors should sit naturally with v4 accent #B84A22 (warm earth, sophisticated)
Strategy: vary HUE only at fixed L+C bands to guarantee equal perceptual spacing.
"""
import colour
import numpy as np
def srgb_hex(rgb):
rgb = np.clip(rgb, 0.0, 1.0)
r, g, b = (round(c * 255) for c in rgb)
return f"#{r:02X}{g:02X}{b:02X}"
def oklch_to_srgb(L, C, H_deg):
H = np.deg2rad(H_deg)
a = C * np.cos(H)
b = C * np.sin(H)
Lab = np.array([L, a, b])
XYZ = colour.Oklab_to_XYZ(Lab)
rgb_lin = colour.XYZ_to_sRGB(XYZ, apply_cctf_encoding=False)
rgb = colour.cctf_encoding(np.clip(rgb_lin, 0, 1), function="sRGB")
return rgb
def srgb_to_lab(rgb):
rgb_lin = colour.cctf_decoding(rgb, function="sRGB")
XYZ = colour.sRGB_to_XYZ(rgb_lin, apply_cctf_decoding=False)
Lab = colour.XYZ_to_Lab(XYZ)
return Lab
def relative_luminance(rgb):
rgb = np.clip(rgb, 0, 1)
rgb_lin = np.where(rgb <= 0.03928, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
return float(0.2126 * rgb_lin[0] + 0.7152 * rgb_lin[1] + 0.0722 * rgb_lin[2])
def contrast_ratio(rgb_a, rgb_b):
L1 = relative_luminance(rgb_a)
L2 = relative_luminance(rgb_b)
if L1 < L2:
L1, L2 = L2, L1
return (L1 + 0.05) / (L2 + 0.05)
def hex_to_rgb(h):
h = h.lstrip("#")
return np.array([int(h[i : i + 2], 16) / 255 for i in (0, 2, 4)])
# ---------- 14 funnel statuses ----------
STATUSES = [
"Новая", # 1
"В работе", # 2
"Дозвон", # 3
"Не дозвон.", # 4
"Перегов.", # 5
"КП отправл.", # 6
"Думает", # 7
"Ждёт оплату", # 8
"Оплачено", # 9
"Возврат", # 10
"Отказ", # 11
"Дубликат", # 12
"Спам", # 13
"Архив", # 14
]
INK = hex_to_rgb("#0B1320")
CREAM = hex_to_rgb("#F4EFE5")
# Tint band (chip background): high lightness, low chroma → light tinted bg
TINT_L = 0.92
TINT_C = 0.055
# Solid band (status dot, strong fill): mid lightness, mid chroma
# C=0.15 chosen so all 14 hues stay in sRGB gamut AND every neighbour pair >= 10 ΔE2000
SOLID_L = 0.55
SOLID_C = 0.15
# 14 hues — start equally distributed, then iteratively re-space by ΔE2000 repulsion.
# This solves the sRGB gamut anisotropy: magenta/pink region needs more degrees of separation
# than green/cyan region to give equal perceptual distance.
START_HUE = 30.0
hues = [(START_HUE + i * (360.0 / 14)) % 360 for i in range(14)]
def _solid_lab(h):
rgb = oklch_to_srgb(SOLID_L, SOLID_C, h)
return srgb_to_lab(rgb)
def _min_neighbour_de(hs):
labs = [_solid_lab(h) for h in hs]
return min(
float(colour.delta_E(labs[i], labs[(i + 1) % len(hs)], method="CIE 2000"))
for i in range(len(hs))
)
# Iteratively perturb until min ΔE >= 10 (or 200 iterations)
_step = 1.0
for _it in range(200):
labs = [_solid_lab(h) for h in hues]
des = [
float(colour.delta_E(labs[i], labs[(i + 1) % len(hues)], method="CIE 2000"))
for i in range(len(hues))
]
if min(des) >= 10.0:
break
# for each hue, push away from the closer neighbour
new_hues = list(hues)
for i in range(len(hues)):
prev_de = des[(i - 1) % len(hues)] # ΔE to previous
next_de = des[i] # ΔE to next
# push toward the larger gap
if next_de < prev_de:
new_hues[i] = (hues[i] - _step) % 360
else:
new_hues[i] = (hues[i] + _step) % 360
hues = new_hues
_step *= 0.95 # cooling
HUES = hues
print(f"# Final hues after re-spacing: {[round(h,1) for h in HUES]}")
print(f"# min neighbour ΔE2000 = {_min_neighbour_de(HUES):.2f}")
print()
print("=" * 84)
print(f"{'#':<3} {'Status':<14} {'Hue°':>5} {'Tint hex':>9} {'Solid hex':>10} {'AA on tint':>11} {'3:1 vs cream':>12}")
print("=" * 84)
results = []
for i, (name, h) in enumerate(zip(STATUSES, HUES), 1):
tint_rgb = oklch_to_srgb(TINT_L, TINT_C, h)
solid_rgb = oklch_to_srgb(SOLID_L, SOLID_C, h)
tint_hex = srgb_hex(tint_rgb)
solid_hex = srgb_hex(solid_rgb)
# WCAG: ink text on tint chip
ink_on_tint = contrast_ratio(INK, tint_rgb)
# WCAG: solid color as a dot/border on cream — needs 3:1 (UI component)
solid_on_cream = contrast_ratio(solid_rgb, CREAM)
aa_tint = "PASS" if ink_on_tint >= 4.5 else "FAIL"
aa_solid = "PASS" if solid_on_cream >= 3.0 else "FAIL"
print(f"{i:<3} {name:<14} {h:5.1f} {tint_hex:>9} {solid_hex:>10} {ink_on_tint:5.2f} {aa_tint} {solid_on_cream:5.2f} {aa_solid}")
results.append((name, h, tint_rgb, solid_rgb, tint_hex, solid_hex))
# ΔE2000 between adjacent solid pairs (cyclic)
print()
print("ΔE2000 between adjacent SOLID pairs (must be >= 10):")
print("-" * 60)
deltas = []
for i in range(14):
a = results[i][3]
b = results[(i + 1) % 14][3]
Lab_a = srgb_to_lab(a)
Lab_b = srgb_to_lab(b)
de = float(colour.delta_E(Lab_a, Lab_b, method="CIE 2000"))
deltas.append(de)
flag = "" if de >= 10 else ""
print(f" {results[i][0]:<14}{results[(i+1)%14][0]:<14} ΔE2000 = {de:5.2f} {flag}")
print()
print(f"min ΔE2000 = {min(deltas):.2f}, max = {max(deltas):.2f}, mean = {sum(deltas)/len(deltas):.2f}")
print()
# also output as CSS custom properties block
print("=" * 84)
print("CSS — drop-in for v6_deals.html")
print("=" * 84)
for i, (name, h, _, _, tint_hex, solid_hex) in enumerate(results, 1):
slug_map = {
"Новая": "new", "В работе": "work", "Дозвон": "call",
"Не дозвон.": "nocall", "Перегов.": "neg", "КП отправл.": "quote",
"Думает": "think", "Ждёт оплату": "wait", "Оплачено": "paid",
"Возврат": "refund", "Отказ": "fail", "Дубликат": "dup",
"Спам": "spam", "Архив": "arch",
}
slug = slug_map[name]
print(f" --st-{slug}-tint: {tint_hex}; /* {name} bg */")
print(f" --st-{slug}-solid: {solid_hex}; /* {name} dot */")