diff --git a/README.md b/README.md index 6f1636a..198bdf5 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ cd claude-panel - клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux; - цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен; - переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются; -- цвет токенов: жёлтый > 500k, красный > 700k. +- цвет токенов: жёлтый > 500k, красный > 700k; +- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится); + выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию. ## Настройки diff --git a/files/claude_agents.py b/files/claude_agents.py index f47a22e..8e99928 100644 --- a/files/claude_agents.py +++ b/files/claude_agents.py @@ -831,6 +831,33 @@ def _render_tree() -> str: return "".join(parts) +# Фон панели выбирается на клиенте (⚙ внизу) и хранится в localStorage этого браузера/web view; +# текст и приглушённый текст подбираются под яркость фона, цвета статусов не меняются. +BG_PRESETS = ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3") +LIGHT_BG_TEXT = "#3b4a50" # текст на светлом фоне; на тёмном — fg темы по умолчанию +BG_STORAGE_KEY = "claude-panel-bg" +CLIENT_COLORS_JS = """function panelColors(bg) { + if (typeof bg !== 'string' || !/^#[0-9a-f]{6}$/i.test(bg)) return null; + bg = bg.toLowerCase(); + const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16)); + const lin = c => (c /= 255) <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + const [r, g, b] = rgb(bg).map(lin); // относительная яркость по WCAG + const fg = 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.4 ? '__LIGHT__' : '__DARK__'; + const f = rgb(fg), k = rgb(bg); // dim — как у темы: 60% текста, 40% фона + const dim = '#' + f.map((c, i) => Math.round(c * 0.6 + k[i] * 0.4).toString(16).padStart(2, '0')).join(''); + return {bg, fg, dim}; +}""".replace("__LIGHT__", LIGHT_BG_TEXT).replace("__DARK__", DEFAULT_THEME["fg"]) + + +def render_bgctl() -> str: + swatches = "".join(f'' + for c in BG_PRESETS) + return (f'
{swatches}' + f'' + f'сброс
' + f'⚙
') + + def render_page() -> str: t = state["theme"] return f""" @@ -857,7 +884,23 @@ def render_page() -> str: .fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }} .sub {{ color:var(--dim); }} .split {{ margin:-2px 0 7px; }} - .foot {{ margin-top:2px; opacity:.8; }} + .foot {{ margin-top:2px; opacity:.8; display:flex; align-items:center; }} + .gear-slot {{ margin-left:auto; }} + /* фон на этом устройстве: ⚙ в строке «обновлено», образцы — под блоком лимитов */ + #bgctl {{ flex:none; padding:0 10px; }} + #gear-home {{ display:flex; justify-content:flex-end; }} + #gear {{ display:inline-flex; align-items:center; justify-content:center; min-width:36px; min-height:36px; + margin:-10px -8px; cursor:pointer; color:var(--dim); font-size:15px; }} + #gear:hover {{ color:var(--fg); }} + #bgrow {{ display:none; gap:4px; align-items:center; flex-wrap:wrap; padding:0 0 8px; }} + #bgrow.open {{ display:flex; }} + .sw {{ display:inline-flex; align-items:center; justify-content:center; width:36px; height:36px; cursor:pointer; }} + .sw i {{ width:24px; height:24px; border-radius:50%; box-sizing:border-box; + border:1px solid color-mix(in srgb, var(--fg) 35%, transparent); }} + #bg-custom {{ width:36px; height:36px; padding:4px; border:none; background:none; cursor:pointer; }} + #bg-reset {{ display:inline-flex; align-items:center; min-height:36px; padding:0 6px; margin-left:auto; + color:var(--dim); cursor:pointer; }} + #bg-reset:hover {{ color:var(--fg); }} .foot.stale {{ color:var(--yellow); }} .session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }} .session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }} @@ -916,9 +959,21 @@ def render_page() -> str: .msg-h {{ color:var(--dim); margin-bottom:2px; white-space:normal; }} .msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }} .trimmed {{ color:var(--dim); font-style:italic; margin-bottom:8px; white-space:normal; }} - + +
{render_tree()}
{render_limits()}
+{render_bgctl()}
↗ вкладка @@ -937,7 +992,11 @@ def render_page() -> str: async function refreshLimits() {{ try {{ const r = await fetch('/limits', {{cache: 'no-store'}}); - if (r.ok) document.getElementById('limits').innerHTML = await r.text(); + if (r.ok) {{ + document.getElementById('gear-home').appendChild(gear); // иначе innerHTML уничтожит ⚙ + document.getElementById('limits').innerHTML = await r.text(); + placeGear(); + }} }} catch (e) {{}} }} setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется @@ -1015,7 +1074,37 @@ def render_page() -> str: document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }}); setInterval(loadMsg, 2000); - // тема могла смениться — раз в минуту перезагружаем страницу (если сообщение не открыто) + // ---- фон панели на этом устройстве ---- + const gear = document.getElementById('gear'), bgrow = document.getElementById('bgrow'); + const bgCustom = document.getElementById('bg-custom'); + function placeGear() {{ + const slot = document.querySelector('#limits .gear-slot'); + (slot || document.getElementById('gear-home')).appendChild(gear); + }} + function saveBg(hex) {{ + if (!applyBg(hex)) return; + bgCustom.value = hex.toLowerCase(); + try {{ localStorage.setItem('{BG_STORAGE_KEY}', hex.toLowerCase()); }} catch (e) {{}} + }} + gear.addEventListener('click', e => {{ e.stopPropagation(); bgrow.classList.toggle('open'); }}); + bgrow.addEventListener('click', e => {{ + e.stopPropagation(); + const sw = e.target.closest('.sw'); + if (sw) saveBg(sw.dataset.bg); + }}); + bgCustom.addEventListener('input', () => saveBg(bgCustom.value)); + document.getElementById('bg-reset').addEventListener('click', () => {{ + applyBg(null); + bgCustom.value = '{DEFAULT_THEME["bg"]}'; + try {{ localStorage.removeItem('{BG_STORAGE_KEY}'); }} catch (e) {{}} + }}); + try {{ + const saved = localStorage.getItem('{BG_STORAGE_KEY}'); + if (panelColors(saved)) bgCustom.value = saved; + }} catch (e) {{}} + placeGear(); + + // раз в минуту перезагружаем страницу (если сообщение не открыто) — подхватить новую версию службы setInterval(() => {{ if (!cur) location.reload(); }}, 60000); """ @@ -1238,7 +1327,8 @@ def render_limits() -> str: if state["usage_updated"]: upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S") stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры - parts.append(f'
обновлено {upd}
') + parts.append(f'
обновлено {upd}' + f'
') return "".join(parts) diff --git a/tests/test_appearance.py b/tests/test_appearance.py new file mode 100644 index 0000000..0f2bfd1 --- /dev/null +++ b/tests/test_appearance.py @@ -0,0 +1,61 @@ +import json +import os +import shutil +import subprocess +import sys +import time +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "files")) +import claude_agents as ca # noqa: E402 + + +class Page(unittest.TestCase): + def test_page_applies_saved_bg_before_body(self): + html = ca.render_page() + body = html.index("") + self.assertLess(html.index("claude-panel-bg"), body) + self.assertIn(ca.CLIENT_COLORS_JS, html) + self.assertIn("try {", html[:body]) + + def test_page_has_bg_controls(self): + html = ca.render_page() + for marker in ('id="bgctl"', 'id="gear"', 'id="bgrow"', 'type="color"', 'id="bg-reset"'): + self.assertIn(marker, html) + self.assertEqual(ca.BG_PRESETS, ("#002b36", "#1e1e1e", "#000000", "#2b2b3a", "#fdf6e3")) + for hex_ in ca.BG_PRESETS: + self.assertIn(f'data-bg="{hex_}"', html) + limits_end = html.index('
') + len('
') + len(ca.render_limits()) + self.assertGreater(html.index('id="bgctl"'), limits_end) + + +class Limits(unittest.TestCase): + def setUp(self): + self.saved = dict(ca.state) + + def tearDown(self): + ca.state.clear() + ca.state.update(self.saved) + + def test_limits_foot_has_gear_slot(self): + ca.state["usage_updated"], ca.state["usage_error"] = time.time(), None + self.assertIn('class="gear-slot"', ca.render_limits()) + + +@unittest.skipUnless(shutil.which("node"), "нет node") +class ClientColors(unittest.TestCase): + def test_panel_colors_js(self): + js = ca.CLIENT_COLORS_JS + ( + "\nconsole.log(JSON.stringify([panelColors('#fdf6e3'), panelColors('#000000')," + " panelColors('nope'), panelColors('#002b36')]))") + out = subprocess.run(["node", "-e", js], capture_output=True, text=True, check=True).stdout + self.assertEqual(json.loads(out), [ + {"bg": "#fdf6e3", "fg": "#3b4a50", "dim": "#898f8b"}, + {"bg": "#000000", "fg": "#aacace", "dim": "#66797c"}, + None, + {"bg": "#002b36", "fg": "#aacace", "dim": "#668a91"}, + ]) + + +if __name__ == "__main__": + unittest.main()