887abf444e
Получен 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>
195 lines
6.4 KiB
Python
195 lines
6.4 KiB
Python
"""
|
||
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 */")
|