feat: client-side panel background with gear control (#4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-18 21:00:08 +04:00
parent 7958314547
commit 2e8ab31bbd
3 changed files with 159 additions and 6 deletions

View File

@ -53,7 +53,9 @@ cd claude-panel
- клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux; - клик по `⎇ папка` — открыть в ней `lf` в соседней панели tmux;
- цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен; - цвет точки: зелёный — работает, жёлтый — ждёт вас, серый — свободен;
- переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются; - переходы и `lf` работают для сессий в tmux; сессии вне tmux только показываются;
- цвет токенов: жёлтый > 500k, красный > 700k. - цвет токенов: жёлтый > 500k, красный > 700k;
- ⚙ в нижней строке — фон панели на этом устройстве (образцы или свой цвет, текст подстроится);
выбор хранится в этом браузере, «сброс» возвращает тему по умолчанию.
## Настройки ## Настройки

View File

@ -831,6 +831,33 @@ def _render_tree() -> str:
return "".join(parts) 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'<span class="sw" data-bg="{c}" title="{c}"><i style="background:{c}"></i></span>'
for c in BG_PRESETS)
return (f'<div id="bgctl"><div id="bgrow">{swatches}'
f'<input type="color" id="bg-custom" title="свой цвет" value="{DEFAULT_THEME["bg"]}">'
f'<span id="bg-reset">сброс</span></div>'
f'<span id="gear-home"><span id="gear" title="Фон панели">⚙</span></span></div>')
def render_page() -> str: def render_page() -> str:
t = state["theme"] t = state["theme"]
return f"""<!doctype html><html><head><meta charset="utf-8"> return f"""<!doctype html><html><head><meta charset="utf-8">
@ -857,7 +884,23 @@ def render_page() -> str:
.fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }} .fill.warn {{ background:var(--yellow); }} .fill.crit {{ background:var(--red); }}
.sub {{ color:var(--dim); }} .sub {{ color:var(--dim); }}
.split {{ margin:-2px 0 7px; }} .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); }} .foot.stale {{ color:var(--yellow); }}
.session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }} .session {{ padding:6px 6px 8px; border-radius:6px; cursor:pointer; }}
.session:hover {{ background:color-mix(in srgb, var(--fg) 8%, transparent); }} .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 {{ color:var(--dim); margin-bottom:2px; white-space:normal; }}
.msg-h .user {{ color:var(--yellow); }} .msg-h .assistant {{ color:var(--green); }} .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; }} .trimmed {{ color:var(--dim); font-style:italic; margin-bottom:8px; white-space:normal; }}
</style></head><body> </style>
<script>
{CLIENT_COLORS_JS}
function applyBg(hex) {{
const c = panelColors(hex), s = document.documentElement.style;
if (!c) {{ ['--bg', '--fg', '--dim'].forEach(p => s.removeProperty(p)); return false; }}
s.setProperty('--bg', c.bg); s.setProperty('--fg', c.fg); s.setProperty('--dim', c.dim);
return true;
}}
// до отрисовки — чтобы при каждой перезагрузке не мелькала тема по умолчанию
try {{ applyBg(localStorage.getItem('{BG_STORAGE_KEY}')); }} catch (e) {{}}
</script></head><body>
<div id="root">{render_tree()}</div> <div id="root">{render_tree()}</div>
<div id="limits">{render_limits()}</div> <div id="limits">{render_limits()}</div>
{render_bgctl()}
<div id="ov"> <div id="ov">
<div class="ov-head"><span class="ov-title"></span> <div class="ov-head"><span class="ov-title"></span>
<span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span> <span class="ov-btn" id="ov-tab" title="Перейти во вкладку сессии">↗ вкладка</span>
@ -937,7 +992,11 @@ def render_page() -> str:
async function refreshLimits() {{ async function refreshLimits() {{
try {{ try {{
const r = await fetch('/limits', {{cache: 'no-store'}}); 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) {{}} }} catch (e) {{}}
}} }}
setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется setInterval(refreshLimits, 15000); // «сброс через …» тоже обновляется
@ -1015,7 +1074,37 @@ def render_page() -> str:
document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }}); document.addEventListener('keydown', e => {{ if (e.key === 'Escape') closeMsg(); }});
setInterval(loadMsg, 2000); 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); setInterval(() => {{ if (!cur) location.reload(); }}, 60000);
</script> </script>
</body></html>""" </body></html>"""
@ -1238,7 +1327,8 @@ def render_limits() -> str:
if state["usage_updated"]: if state["usage_updated"]:
upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S") upd = datetime.fromtimestamp(state["usage_updated"]).strftime("%H:%M:%S")
stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры stale = state["usage_error"] is not None # последнее обновление не удалось — показаны старые цифры
parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}</div>') parts.append(f'<div class="sub foot{" stale" if stale else ""}">обновлено {upd}'
f'<span class="gear-slot"></span></div>')
return "".join(parts) return "".join(parts)

61
tests/test_appearance.py Normal file
View File

@ -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("<body>")
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('<div id="limits">') + len('<div id="limits">') + 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()