""" VersaPad Viewer -- Browser-Variante. Liest bei jedem Request die kombinierte Config-JSON vom Desktop und rendert die Steuermatrix (4x5 Grid + 4 Encoder) je Profil als HTML. Fehlt die JSON (z.B. geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und als neuer Cache gespeichert -- das Board ist die eigentliche Quelle der Wahrheit, siehe versapad_combined.load_or_fetch(). Kein Build-Schritt, kein externes Framework -- nur stdlib. Start: python server.py [--port 8765] """ import argparse import html import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs import versapad_combined as vcomb import versapad_data as vp PAGE_CSS = """ :root { color-scheme: dark; } * { box-sizing: border-box; } body { margin: 0; padding: 32px 24px 64px; background: #14161b; color: #e8e8ec; font-family: -apple-system, "Segoe UI", Roboto, sans-serif; } h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .sub { color: #8a8d98; font-size: 13px; margin-bottom: 24px; } .tabs { display: flex; gap: 8px; margin-bottom: 28px; } .tab { padding: 8px 16px; border-radius: 8px; text-decoration: none; color: #c4c6cf; background: #1e2129; font-size: 14px; font-weight: 500; border: 1px solid #2a2d37; } .tab.active { background: #3a6ff0; color: #fff; border-color: #3a6ff0; } .grid { display: grid; grid-template-columns: repeat(4, 140px); grid-template-rows: repeat(5, 76px); grid-auto-flow: column; gap: 10px; margin-bottom: 36px; } .cell { position: relative; border-radius: 10px; padding: 8px 10px; display: flex; flex-direction: column; justify-content: flex-end; background: #1e2129; border: 1px solid #2a2d37; overflow: hidden; } .cell .swatch { position: absolute; top: 0; left: 0; right: 0; height: 6px; } .cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; } .cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; } .cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; } .cell.empty .label { color: #4a4d58; font-weight: 400; } h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; } .encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; } .enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; } .enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; } .enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; } .enc .row .k { color: #8a8d98; } .enc .row .v { font-weight: 500; text-align: right; } footer { margin-top: 40px; color: #6a6d78; font-size: 12px; } """ def render_cell(btn): empty = btn["action"]["type"] == "None" anim = vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) css_class = "cell empty" if empty else "cell" grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 style = f"grid-column:{grid_col}; grid-row:{grid_row};" label = html.escape(btn["label"]) if btn["label"] else "—" return f"""