From 312b4083d290d47f204cfe82345dc2d57a684746 Mon Sep 17 00:00:00 2001 From: viewit Date: Thu, 21 May 2026 14:35:25 +0200 Subject: [PATCH] build: sources for v0.9.14 --- .gitignore | 2 + CHANGELOG.de.md | 20 + CHANGELOG.md | 20 + Dockerfile | 2 +- VERSION | 2 +- kobrax_moonraker_bridge.py | 105 ++- kx-bridge.spec | 45 + web/DOC/THEME-CSS-HOOKS.md | 136 ++++ web/DOC/THEME-JS-ID-HOOKS.md | 86 ++ _web_assets.py => web/themes/default/app.js | 859 -------------------- web/themes/default/index.html | 560 +++++++++++++ web/themes/default/style.css | 293 +++++++ 12 files changed, 1261 insertions(+), 869 deletions(-) create mode 100644 kx-bridge.spec create mode 100644 web/DOC/THEME-CSS-HOOKS.md create mode 100644 web/DOC/THEME-JS-ID-HOOKS.md rename _web_assets.py => web/themes/default/app.js (64%) create mode 100644 web/themes/default/index.html create mode 100644 web/themes/default/style.css diff --git a/.gitignore b/.gitignore index d86da6a..65cb7f6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ dist/ releases/*/kx-bridge releases/*/extract_credentials releases/*/extract_credentials.exe + +!kx-bridge.spec diff --git a/CHANGELOG.de.md b/CHANGELOG.de.md index f190ad8..29ddc8f 100644 --- a/CHANGELOG.de.md +++ b/CHANGELOG.de.md @@ -1,5 +1,25 @@ # Changelog +## [0.9.14] – 2026-05-21 + +### Neu +- **Theme-System (Community-Beitrag von @hirnwunde, PR #27):** Die Web-UI liegt + jetzt in echten Dateien unter `web/themes//` (`index.html` + `style.css` + + `app.js`) statt im Python-Quelltext eingebettet. Theme umschalten mit + `--ui-theme `. Für Theme-Autoren gibt es eine dokumentierte Hook-Referenz + (`web/DOC/THEME-CSS-HOOKS.md`, `THEME-JS-ID-HOOKS.md`). Das Default-Theme + enthält die komplette aktuelle UI (ACE2, Objekte überspringen, Filament-Dialog). + Für Nutzer keine Änderung — Binaries/Docker-Image liefern das Theme eingebettet. +- **Neustart über API (Community-Beitrag von @gangoke, PR #28):** neuer Endpoint + `POST /api/restart`, um die Bridge per API neu zu starten — z. B. für einen + Neustart-Button in der Home-Assistant-Integration. + +### Intern +- Vereinheitlichter PyInstaller-Build (`kx-bridge.spec`) für Linux, Windows und + Docker — bindet `web/` (Themes) ins Onefile-Binary ein, zur Laufzeit aus + `sys._MEIPASS` gelesen. Theme-Einbettung in Linux-Binary und Windows-EXE verifiziert. +- `data/` in `.gitignore` aufgenommen. + ## [0.9.13] – 2026-05-20 ============================================================ diff --git a/CHANGELOG.md b/CHANGELOG.md index f39663d..183ea6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [0.9.14] – 2026-05-21 + +### New +- **Theme system (community contribution by @hirnwunde, PR #27):** the web UI now + lives in real files under `web/themes//` (`index.html` + `style.css` + + `app.js`) instead of being embedded in the Python source. Switch themes with + `--ui-theme `. Theme authors get a documented hook reference + (`web/DOC/THEME-CSS-HOOKS.md`, `THEME-JS-ID-HOOKS.md`). The default theme + carries the full current UI (ACE2, skip objects, filament dialog). No change + for users — the bundled binaries/Docker image ship the theme embedded. +- **Restart over API (community contribution by @gangoke, PR #28):** new + `POST /api/restart` endpoint to restart the bridge remotely — e.g. a restart + button in the Home Assistant integration. + +### Internal +- Unified PyInstaller build (`kx-bridge.spec`) for Linux, Windows and Docker — + embeds `web/` (themes) into the one-file binary, read at runtime from + `sys._MEIPASS`. Verified the theme ships in the Linux binary and the Windows EXE. +- `data/` added to `.gitignore`. + ## [0.9.13] – 2026-05-20 ============================================================ diff --git a/Dockerfile b/Dockerfile index c474419..ee63023 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY kobrax_moonraker_bridge.py . -COPY _web_assets.py . +COPY web/ ./web/ COPY config_loader.py . COPY env_loader.py . COPY kobrax_client.py . diff --git a/VERSION b/VERSION index 62ea259..6d44d22 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.13 +0.9.14 diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 42d6c5e..28ab773 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -44,12 +44,15 @@ import sys import tempfile import time import threading +import html # Bei PyInstaller-Binary liegt alles neben sys.executable, sonst neben __file__ _BASE = os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, _BASE) +# Read-Only Web-Assets (Themes) werden im Onefile-Binary via --add-data unter +# sys._MEIPASS entpackt; im Script-/Docker-Modus liegen sie neben dieser Datei. +_WEB_BASE = getattr(sys, "_MEIPASS", _BASE) from kobrax_client import KobraXClient -from _web_assets import INDEX_HTML try: @@ -130,6 +133,14 @@ logging.basicConfig(level=logging.INFO, datefmt="%H:%M:%S") log = logging.getLogger("bridge") +# Web-UI: Unterverzeichnis unter web/themes//index.html +_UI_THEME_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$") +# Erlaubte statische Theme-Dateien unter /kx/ui/ +_KX_UI_ASSETS: dict[str, str] = { + "style.css": "text/css", + "app.js": "application/javascript", +} + # Ring-Buffer für Browser-Log-Stream (letzte 200 Einträge) import collections as _collections _log_buffer: "_collections.deque[dict]" = _collections.deque(maxlen=500) @@ -550,6 +561,15 @@ class KobraXBridge: # Part-Skip: zuletzt vom Drucker gemeldete Skip-Liste (v0.9.10) self._skip_state: dict = {"objects": [], "skipped": [], "ts": 0} + # Theme-Name prüfen (keine Sonderzeichen oder Umlaute) + raw_theme = (getattr(args, "ui_theme", None) or "default").strip() + if not _UI_THEME_NAME_RE.match(raw_theme): + log.warning("Ungültiger UI-Theme-Name %r – nutze default", raw_theme) + raw_theme = "default" + self._ui_theme = raw_theme + self._index_tpl_cache: str | None = None + self._index_tpl_cache_key: tuple[str, float] | None = None + # Register MQTT push callbacks client.callbacks["tempature/report"] = self._on_temp client.callbacks["print/report"] = self._on_print @@ -1867,6 +1887,30 @@ class KobraXBridge: else: log.warning("Druckstart: keine Antwort vom Drucker") + def _theme_index_path(self) -> str: + return os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, "index.html") + + def _load_index_template_cached(self) -> str: + path = self._theme_index_path() + mtime = os.path.getmtime(path) + key = (path, mtime) + if self._index_tpl_cache is not None and self._index_tpl_cache_key == key: + return self._index_tpl_cache + with open(path, "r", encoding="utf-8") as f: + self._index_tpl_cache = f.read() + self._index_tpl_cache_key = key + return self._index_tpl_cache + + def _ui_asset_cache_buster(self) -> str: + base = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme) + mt = 0.0 + for fn in ("index.html", "style.css", "app.js"): + try: + mt = max(mt, os.path.getmtime(os.path.join(base, fn))) + except OSError: + pass + return str(int(mt)) if mt else "0" + async def handle_print_start(self, request): try: body = await request.json() @@ -1974,10 +2018,39 @@ class KobraXBridge: "text": "OctoPrint (Kobra X Bridge)", }) + async def handle_kx_ui_asset(self, request): + name = request.match_info.get("name", "") + ctype = _KX_UI_ASSETS.get(name) + if ctype is None: + raise web.HTTPNotFound() + path = os.path.join(_WEB_BASE, "web", "themes", self._ui_theme, name) + try: + raw = pathlib.Path(path).read_text(encoding="utf-8") + except OSError: + raise web.HTTPNotFound() + if name == "app.js": + raw = raw.replace("'__VERSION__'", f"'{self._read_version()}'") + return web.Response( + text=raw, + content_type=ctype, + headers={"Cache-Control": "public, max-age=86400"}, + ) + async def handle_index(self, request): - html = INDEX_HTML - version = self._read_version() - html = html.replace("'__VERSION__'", f"'{version}'") + try: + tpl = self._load_index_template_cached() + except OSError: + p = self._theme_index_path() + log.error("Web-UI Theme-Datei fehlt oder nicht lesbar: %s (Theme: %s)", p, self._ui_theme) + return web.Response( + text="
KX-Bridge: index.html nicht gefunden.\nErwartet:\n"
+                + html.escape(p, quote=True)
+                + "
", + status=500, + content_type="text/html; charset=utf-8", + ) + html = tpl.replace("__UI_ASSETS_VER__", self._ui_asset_cache_buster()) + return web.Response(text=html, content_type="text/html", headers={"Cache-Control": "no-store, no-cache, must-revalidate"}) @@ -2033,6 +2106,12 @@ class KobraXBridge: log.info("Manuell getrennt") return web.json_response({"result": "disconnected"}) + async def handle_api_restart(self, request): + log.info("Neustart über API angefordert") + response = web.json_response({"status": "restarting"}) + asyncio.get_event_loop().call_later(0.3, self._restart_bridge) + return response + async def handle_api_speed(self, request): try: body = await request.json() @@ -2877,12 +2956,13 @@ class KobraXBridge: except Exception as e: return web.json_response({"error": str(e)}, status=502) - # Bridge-Python-Module, die das Self-Update mitziehen muss. Die Hauptdatei - # importiert _web_assets (gebündeltes Frontend) etc. – wird nur die Hauptdatei - # ersetzt, crasht die neue Version mit ModuleNotFoundError. Daher alle laden. + # Bridge-Python-Module, die das Self-Update mitziehen muss. Wird nur die + # Hauptdatei ersetzt, crasht die neue Version ggf. mit ModuleNotFoundError. + # Hinweis: das Frontend liegt seit dem Theme-System unter web/themes// + # (keine flache .py mehr); Theme-Dateien werden vom Self-Update derzeit NICHT + # mitgeladen – Theme-Änderungen kommen über Docker-Image/Binary-Update. _UPDATE_FILES = [ "kobrax_moonraker_bridge.py", - "_web_assets.py", "kobrax_client.py", "config_loader.py", "env_loader.py", @@ -3213,6 +3293,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_post("/api/fan", bridge.handle_api_fan) r.add_post("/api/connect", bridge.handle_api_connect) r.add_post("/api/disconnect", bridge.handle_api_disconnect) + r.add_post("/api/restart", bridge.handle_api_restart) r.add_post("/api/speed", bridge.handle_api_speed) r.add_post("/api/ams/feed", bridge.handle_api_ams_feed) r.add_post("/api/ams/set_slot", bridge.handle_api_ams_set_slot) @@ -3243,6 +3324,7 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete) r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots) r.add_get("/kx/history", bridge.handle_kx_history) + r.add_get("/kx/ui/{name}", bridge.handle_kx_ui_asset) r.add_get("/kx/files/{id}/objects", bridge.handle_kx_file_objects) r.add_post("/kx/skip", bridge.handle_kx_skip) r.add_post("/kx/skip/query", bridge.handle_kx_skip_query) @@ -3412,6 +3494,13 @@ def main(): help="HTTP/WS-Port (Moonraker-Standard: 7125)") parser.add_argument("--data-dir", default=_default_data_dir(), help="Persistenz-Verzeichnis für GCode-Store und DB") + parser.add_argument( + "--ui-theme", + default=os.environ.get("KX_UI_THEME", "default"), + metavar="NAME", + help="Web-UI-Theme (Ordner web/themes/NAME/, Standard: default). " + "Alternativ: Umgebungsvariable KX_UI_THEME.", + ) args = parser.parse_args() if args.printer_ip and ":" in args.printer_ip: args.printer_ip = args.printer_ip.split(":")[0] diff --git a/kx-bridge.spec b/kx-bridge.spec new file mode 100644 index 0000000..efbc7c1 --- /dev/null +++ b/kx-bridge.spec @@ -0,0 +1,45 @@ +# PyInstaller-Spec für kx-bridge — plattformneutral (Linux + Windows via PyBuilder). +# Wird relativ zum Repo-Root ausgeführt (`pyinstaller kx-bridge.spec`), wo +# kobrax_moonraker_bridge.py und web/ flach liegen (Release-Repo-Layout). +# +# Bindet das Web-Theme-System (web/themes// + web/DOC/) ins Onefile-Binary +# ein → zur Laufzeit über sys._MEIPASS lesbar (_WEB_BASE in der Bridge). +from PyInstaller.utils.hooks import collect_all + +datas = [("web", "web")] +binaries = [] +hiddenimports = [] + +# pycryptodome vollständig einsammeln (Krypto für die Drucker-Auth) +_d, _b, _h = collect_all("pycryptodome") +datas += _d +binaries += _b +hiddenimports += _h + +a = Analysis( + ["kobrax_moonraker_bridge.py"], + pathex=[], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + runtime_hooks=[], + excludes=[], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="kx-bridge", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + onefile=True, +) diff --git a/web/DOC/THEME-CSS-HOOKS.md b/web/DOC/THEME-CSS-HOOKS.md new file mode 100644 index 0000000..f3e7e48 --- /dev/null +++ b/web/DOC/THEME-CSS-HOOKS.md @@ -0,0 +1,136 @@ +# KX-Bridge Theme – CSS-ID-Hooks + +Referenzliste für CSS-/Layout-Anpassungen. + +| ID | Verwendung | +|---|---| +| `#ace-dry-dialog-custom-name-label` | Hook / Selektor | +| `#ace-dry-dialog-custom-name-row` | Hook / Selektor | +| `#ace-dry-dialog-temp-label` | Hook / Selektor | +| `#ace-dry-dialog-time-label` | Hook / Selektor | +| `#ace-dry-dialog-title` | Hook / Selektor | +| `#add-printer-btn-label` | Hook / Selektor | +| `#ams-no-data` | Hook / Selektor | +| `#apd-ip` | Hook / Selektor | +| `#apd-lbl-ip` | Hook / Selektor | +| `#apd-lbl-name` | Hook / Selektor | +| `#apd-name` | Hook / Selektor | +| `#apd-status` | Hook / Selektor | +| `#apd-title` | Hook / Selektor | +| `#btn-log-dl` | Hook / Selektor | +| `#cam-fname` | Hook / Selektor | +| `#cam-img` | Hook / Selektor | +| `#cam-overlay` | Hook / Selektor | +| `#cam-placeholder` | Hook / Selektor | +| `#cam-placeholder-txt` | Hook / Selektor | +| `#cam-spinner` | Hook / Selektor | +| `#cam-wrap` | Hook / Selektor | +| `#conn-error-banner` | Hook / Selektor | +| `#d-ace-dry-grid` | Hook / Selektor | +| `#d-ace-dry-wrap` | Hook / Selektor | +| `#d-ams-card` | Hook / Selektor | +| `#d-bt-t` | Hook / Selektor | +| `#d-btbar` | Hook / Selektor | +| `#d-btn-skip-label` | Hook / Selektor | +| `#d-card-ams` | Hook / Selektor | +| `#d-card-cam` | Hook / Selektor | +| `#d-card-lightfan` | Hook / Selektor | +| `#d-card-progress` | Hook / Selektor | +| `#d-card-speed` | Hook / Selektor | +| `#d-card-temps` | Hook / Selektor | +| `#d-chart-label` | Hook / Selektor | +| `#d-ctrl-btns` | Hook / Selektor | +| `#d-elapsed` | Hook / Selektor | +| `#d-fname` | Hook / Selektor | +| `#d-layers` | Hook / Selektor | +| `#d-lbl-bed` | Hook / Selektor | +| `#d-lbl-elapsed` | Hook / Selektor | +| `#d-lbl-layers` | Hook / Selektor | +| `#d-lbl-light` | Hook / Selektor | +| `#d-lbl-remain` | Hook / Selektor | +| `#d-nt` | Hook / Selektor | +| `#d-nt-t` | Hook / Selektor | +| `#d-ntbar` | Hook / Selektor | +| `#d-pbar` | Hook / Selektor | +| `#d-pct` | Hook / Selektor | +| `#d-remain` | Hook / Selektor | +| `#d-slicer-label` | Hook / Selektor | +| `#d-slicer-row` | Hook / Selektor | +| `#d-slicer-time` | Hook / Selektor | +| `#d-spd-bar` | Hook / Selektor | +| `#d-spd-lbl-1` | Hook / Selektor | +| `#d-spd-lbl-2` | Hook / Selektor | +| `#d-spd-lbl-3` | Hook / Selektor | +| `#d-thumbnail` | Hook / Selektor | +| `#fd-objects` | Hook / Selektor | +| `#fd-objects-hint` | Hook / Selektor | +| `#fd-objects-section` | Hook / Selektor | +| `#fd-objects-svg` | Hook / Selektor | +| `#fd-slots-hint` | Hook / Selektor | +| `#fd-title` | Hook / Selektor | +| `#file-ready-banner` | Hook / Selektor | +| `#file-ready-name` | Hook / Selektor | +| `#h-badge` | Hook / Selektor | +| `#h-pname` | Hook / Selektor | +| `#h-pname-single` | Hook / Selektor | +| `#h-state` | Hook / Selektor | +| `#h-version` | Hook / Selektor | +| `#lbl-auto-leveling` | Hook / Selektor | +| `#lbl-default-slot` | Hook / Selektor | +| `#lbl-device-id` | Hook / Selektor | +| `#lbl-ip-hint` | Hook / Selektor | +| `#lbl-mode-id` | Hook / Selektor | +| `#lbl-mqtt-port` | Hook / Selektor | +| `#lbl-password` | Hook / Selektor | +| `#lbl-printer-ip` | Hook / Selektor | +| `#lbl-printer-name` | Hook / Selektor | +| `#lbl-slot-color` | Hook / Selektor | +| `#lbl-slot-material` | Hook / Selektor | +| `#lbl-update-apply` | Hook / Selektor | +| `#lbl-update-check` | Hook / Selektor | +| `#lbl-username` | Hook / Selektor | +| `#log-badge` | Hook / Selektor | +| `#log-badge-bot` | Hook / Selektor | +| `#modal-sec-connection` | Hook / Selektor | +| `#modal-sec-poll` | Hook / Selektor | +| `#modal-sec-print` | Hook / Selektor | +| `#modal-sec-version` | Hook / Selektor | +| `#modal-title-settings` | Hook / Selektor | +| `#opt-slot-0` | Hook / Selektor | +| `#opt-slot-1` | Hook / Selektor | +| `#opt-slot-2` | Hook / Selektor | +| `#opt-slot-3` | Hook / Selektor | +| `#opt-slot-auto` | Hook / Selektor | +| `#printer-dropdown-menu` | Hook / Selektor | +| `#printer-dropdown-wrap` | Hook / Selektor | +| `#printers-panel-title` | Hook / Selektor | +| `#ptitle-console` | Hook / Selektor | +| `#ptitle-motion-xy` | Hook / Selektor | +| `#ptitle-motion-z` | Hook / Selektor | +| `#s-auto-leveling` | Hook / Selektor | +| `#s-default-slot` | Hook / Selektor | +| `#s-device-id` | Hook / Selektor | +| `#s-mode-id` | Hook / Selektor | +| `#s-mqtt-port` | Hook / Selektor | +| `#s-password` | Hook / Selektor | +| `#s-printer-name` | Hook / Selektor | +| `#s-username` | Hook / Selektor | +| `#s-version-label` | Hook / Selektor | +| `#sf-all` | Hook / Selektor | +| `#sf-err` | Hook / Selektor | +| `#sf-new` | Hook / Selektor | +| `#sf-ok` | Hook / Selektor | +| `#skip-hint` | Hook / Selektor | +| `#skip-list` | Hook / Selektor | +| `#skip-status` | Hook / Selektor | +| `#skip-svg` | Hook / Selektor | +| `#skip-title` | Hook / Selektor | +| `#slot-edit-title` | Hook / Selektor | +| `#ss-date` | Hook / Selektor | +| `#ss-dur` | Hook / Selektor | +| `#ss-name` | Hook / Selektor | +| `#step-display` | Hook / Selektor | +| `#store-empty` | Hook / Selektor | +| `#store-panel-title` | Hook / Selektor | +| `#update-changelog` | Hook / Selektor | +| `#update-status` | Hook / Selektor | diff --git a/web/DOC/THEME-JS-ID-HOOKS.md b/web/DOC/THEME-JS-ID-HOOKS.md new file mode 100644 index 0000000..a8f199b --- /dev/null +++ b/web/DOC/THEME-JS-ID-HOOKS.md @@ -0,0 +1,86 @@ +# KX-Bridge Theme – JavaScript-ID-Hooks + +Referenzliste für JavaScript-/DOM-Hooks. + +| ID | Verwendung | +|---|---| +| `#ace-dry-dialog` | Hook / Selektor | +| `#ace-dry-dialog-cancel` | Hook / Selektor | +| `#ace-dry-dialog-confirm` | Hook / Selektor | +| `#ace-dry-dialog-custom-name` | Hook / Selektor | +| `#ace-dry-dialog-h` | Hook / Selektor | +| `#ace-dry-dialog-m` | Hook / Selektor | +| `#ace-dry-dialog-reset-default` | Hook / Selektor | +| `#ace-dry-dialog-s` | Hook / Selektor | +| `#ace-dry-dialog-save-preset` | Hook / Selektor | +| `#ace-dry-dialog-temp` | Hook / Selektor | +| `#add-printer-dialog` | Hook / Selektor | +| `#ams-slots` | Hook / Selektor | +| `#apd-confirm` | Hook / Selektor | +| `#bnb-console` | Hook / Selektor | +| `#bnb-dashboard` | Hook / Selektor | +| `#bnb-printers` | Hook / Selektor | +| `#bnb-store` | Hook / Selektor | +| `#btn-autoscroll` | Hook / Selektor | +| `#btn-save-settings` | Hook / Selektor | +| `#btn-slot-edit-feed` | Hook / Selektor | +| `#btn-slot-edit-save` | Hook / Selektor | +| `#btn-update-apply` | Hook / Selektor | +| `#btn-update-check` | Hook / Selektor | +| `#cam-toggle-btn` | Hook / Selektor | +| `#conn-btn` | Hook / Selektor | +| `#console-log` | Hook / Selektor | +| `#d-bt` | Hook / Selektor | +| `#d-btn-cancel` | Hook / Selektor | +| `#d-btn-pause` | Hook / Selektor | +| `#d-btn-resume` | Hook / Selektor | +| `#d-btn-skip` | Hook / Selektor | +| `#d-chart` | Hook / Selektor | +| `#d-fan` | Hook / Selektor | +| `#d-fan-val` | Hook / Selektor | +| `#d-light-toggle` | Hook / Selektor | +| `#d-spd-1` | Hook / Selektor | +| `#d-spd-2` | Hook / Selektor | +| `#d-spd-3` | Hook / Selektor | +| `#fd-cancel` | Hook / Selektor | +| `#fd-print` | Hook / Selektor | +| `#fd-slots` | Hook / Selektor | +| `#filament-dialog` | Hook / Selektor | +| `#file-cancel-btn` | Hook / Selektor | +| `#file-ready-btn` | Hook / Selektor | +| `#file-slots-btn` | Hook / Selektor | +| `#lang-btn` | Hook / Selektor | +| `#log-filter` | Hook / Selektor | +| `#logdir-all` | Hook / Selektor | +| `#logdir-rx` | Hook / Selektor | +| `#logdir-tx` | Hook / Selektor | +| `#nb-console` | Hook / Selektor | +| `#nb-dashboard` | Hook / Selektor | +| `#nb-printers` | Hook / Selektor | +| `#nb-store` | Hook / Selektor | +| `#p-bed-inp` | Hook / Selektor | +| `#p-nozzle-inp` | Hook / Selektor | +| `#panel-console` | Hook / Selektor | +| `#panel-dashboard` | Hook / Selektor | +| `#panel-printers` | Hook / Selektor | +| `#panel-store` | Hook / Selektor | +| `#poll-1` | Hook / Selektor | +| `#poll-2` | Hook / Selektor | +| `#poll-5` | Hook / Selektor | +| `#printer-dropdown-btn` | Hook / Selektor | +| `#printers-grid` | Hook / Selektor | +| `#s-printer-ip` | Hook / Selektor | +| `#settings-btn` | Hook / Selektor | +| `#settings-modal` | Hook / Selektor | +| `#skip-confirm` | Hook / Selektor | +| `#skip-dialog` | Hook / Selektor | +| `#slot-edit-color` | Hook / Selektor | +| `#slot-edit-mat` | Hook / Selektor | +| `#slot-edit-modal` | Hook / Selektor | +| `#slot-edit-preview` | Hook / Selektor | +| `#slot-mat-btns` | Hook / Selektor | +| `#store-filter` | Hook / Selektor | +| `#store-grid` | Hook / Selektor | +| `#store-refresh-btn` | Hook / Selektor | +| `#store-search` | Hook / Selektor | +| `#store-sort` | Hook / Selektor | diff --git a/_web_assets.py b/web/themes/default/app.js similarity index 64% rename from _web_assets.py rename to web/themes/default/app.js index 19b3245..d661eb0 100644 --- a/_web_assets.py +++ b/web/themes/default/app.js @@ -1,745 +1,3 @@ -# AUTOGENERIERT von tools/bundle_web_assets.py – NICHT von Hand editieren. -# Quelle: bridge/web/index.html -INDEX_HTML = r""" - - - - -KX-Bridge - - - - - - - -
- -
- -
Anycubic Kobra X
- -
Standby
- - - - -
- - - - - - - -
- - -
- -
-
- -
-
-
📷 Kamera
-
- 💡 Licht - -
-
-
-
📷 Kamera nicht gestartet
-
- - - -
-
- - -
-
Fortschritt
- -
0%
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- - - - -
-
- - -
-
Temperaturen
-
-
-
Nozzle
-
-
-
°C
-
-
0°C
-
-
-
-
- - - -
-
-
-
Bett
-
-
-
°C
-
-
0°C
-
-
-
-
- - - -
-
-
-
-
Verlauf (letzte 60 Messungen)
- -
-
- - -
-
XY-Achsen
-
-
- -
- - - -
- -
-
-
- - - - -
-
- - - - -
-
-
-
Z-Achse
-
- - -
-
Schrittweite: 1 mm
-
- - -
-
🏎 Druckgeschwindigkeit
-
- - - -
-
-
-
-
- - -
-
🌀 Lüfter
-
- - 0 -
-
- - - - - -
-
- - - - -
-
Filament
-
-
Keine AMS-Daten empfangen
-
-
-
-
- - - -
-
-
- 🖨 Drucker -
- - -
-
-
-
-
- - -
-
-
- 🗂 Datei-Browser - -
-
- - - -
- -
-
-
- -
-
-
- Ereignis-Log - ⬇ Download -
-
- - - -
-
- Dir: - - - - Topic: - - - - -
-
-
-
-
-
- - - - - - - - - - - - - - -
- © ViewIT 2026 -
- - -""" diff --git a/web/themes/default/index.html b/web/themes/default/index.html new file mode 100644 index 0000000..f0b5c6d --- /dev/null +++ b/web/themes/default/index.html @@ -0,0 +1,560 @@ + + + + + +KX-Bridge + + + + + + +
+ +
+ +
Anycubic Kobra X
+ +
Standby
+ + + + +
+ + + + + + + +
+ + +
+ +
+
+ +
+
+
📷 Kamera
+
+ 💡 Licht + +
+
+
+
📷 Kamera nicht gestartet
+
+ + + +
+
+ + +
+
Fortschritt
+ +
0%
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + + + +
+
+ + +
+
Temperaturen
+
+
+
Nozzle
+
+
+
°C
+
+
0°C
+
+
+
+
+ + + +
+
+
+
Bett
+
+
+
°C
+
+
0°C
+
+
+
+
+ + + +
+
+
+
+
Verlauf (letzte 60 Messungen)
+ +
+
+ + +
+
XY-Achsen
+
+
+ +
+ + + +
+ +
+
+
+ + + + +
+
+ + + + +
+
+
+
Z-Achse
+
+ + +
+
Schrittweite: 1 mm
+
+ + +
+
🏎 Druckgeschwindigkeit
+
+ + + +
+
+
+
+
+ + +
+
🌀 Lüfter
+
+ + 0 +
+
+ + + + + +
+
+ + + + +
+
Filament
+
+
Keine AMS-Daten empfangen
+
+
+
+
+ + + +
+
+
+ 🖨 Drucker +
+ + +
+
+
+
+
+ + +
+
+
+ 🗂 Datei-Browser + +
+
+ + + +
+ +
+
+
+ +
+
+
+ Ereignis-Log + ⬇ Download +
+
+ + + +
+
+ Dir: + + + + Topic: + + + + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + diff --git a/web/themes/default/style.css b/web/themes/default/style.css new file mode 100644 index 0000000..faea4a5 --- /dev/null +++ b/web/themes/default/style.css @@ -0,0 +1,293 @@ +:root{ + --bg:#1a1a1f;--card:#24242c;--raised:#2e2e3a;--border:#3a3a4a; + --txt:#f0f0f5;--txt2:#8888aa;--accent:#00c8ff;--accent2:#ff6b35; + --ok:#4cde80;--err:#ff4d6d;--warn:#ffb020; + --font:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; + --mono:"JetBrains Mono","Fira Code",monospace; +} +[data-theme=light]{ + --bg:#f0f0f5;--card:#fff;--raised:#e8e8f0;--border:#d0d0e0; + --txt:#1a1a2e;--txt2:#666680; +} +*{box-sizing:border-box;margin:0;padding:0} +body{background:var(--bg);color:var(--txt);font-family:var(--font);font-size:14px;min-height:100vh;display:flex;flex-direction:column} +a{color:var(--accent);text-decoration:none} + +/* ── HEADER ── */ +header{background:var(--card);border-bottom:1px solid var(--border); + display:flex;align-items:center;gap:12px;padding:0 20px;height:52px; + position:sticky;top:0;z-index:100} +.logo{font-size:18px;font-weight:700;color:var(--accent);letter-spacing:-.02em} +.hname{font-size:13px;color:var(--txt2)} +.hbadge{display:flex;align-items:center;gap:6px;font-size:12px;font-weight:600; + padding:4px 10px;border-radius:20px;background:var(--raised);color:var(--txt2); + text-transform:uppercase;letter-spacing:.04em} +.hbadge.printing{background:#0d2d1a;color:var(--ok)} +.hbadge.complete{background:#0d1f38;color:#60b0ff} +.hbadge.error{background:#2d0d0d;color:var(--err)} +.hbadge .dot{width:7px;height:7px;border-radius:50%;background:currentColor} +.hbadge.printing .dot{animation:pulse 1.4s infinite} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.25}} +.theme-btn{background:none;border:1px solid var(--border);color:var(--txt2); + border-radius:8px;padding:6px 10px;cursor:pointer;font-size:13px;transition:.15s} +.theme-btn:hover{border-color:var(--accent);color:var(--accent)} +.conn-btn{border-radius:8px;padding:6px 12px;cursor:pointer;font-size:13px; + font-weight:600;border:none;transition:.15s} +.conn-btn.disconnected{background:var(--accent);color:#fff} +.conn-btn.disconnected:hover{opacity:.85} +.conn-btn.connected{background:transparent;border:1px solid var(--border);color:var(--txt2)} +.conn-btn.connected:hover{border-color:#e05;color:#e05} + +/* ── LAYOUT ── */ +.layout{display:flex;flex:1;min-height:0} +nav.sidebar{width:200px;background:var(--card);border-right:1px solid var(--border); + display:flex;flex-direction:column;padding:12px 8px;gap:2px;flex-shrink:0} +.nav-btn{background:none;border:none;color:var(--txt2);text-align:left; + padding:9px 12px;border-radius:8px;cursor:pointer;font-size:13px; + display:flex;align-items:center;gap:10px;transition:.12s;width:100%} +.nav-btn:hover{background:var(--raised);color:var(--txt)} +.nav-btn.active{background:var(--raised);color:var(--accent)} +.nav-icon{font-size:16px;width:20px;text-align:center} +main{flex:1;overflow-y:auto;padding:20px} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px} + +/* ── CARD ── */ +.card{background:var(--card);border:1px solid var(--border);border-radius:12px; + padding:18px;transition:box-shadow .15s,transform .15s} +.card:hover{box-shadow:0 4px 20px rgba(0,0,0,.3);transform:translateY(-1px)} +.card-title{font-size:11px;text-transform:uppercase;letter-spacing:.1em;color:var(--txt2); + margin-bottom:14px;display:flex;align-items:center;gap:8px} +.card-title span{font-size:14px} + +/* ── HERO ── */ +.hero{grid-column:1/-1;display:grid;grid-template-columns:1fr 320px;gap:16px} +@media(max-width:900px){.hero{grid-template-columns:1fr}} +.cam-wrap{background:#0a0a0e;border-radius:10px;overflow:hidden; + min-height:180px;max-height:320px;display:flex;align-items:center;justify-content:center;position:relative} +.cam-wrap img,.cam-wrap video{width:100%;max-height:320px;height:auto;display:block;object-fit:contain} +.cam-placeholder{color:var(--txt2);font-size:13px;text-align:center;padding:20px} +@keyframes spin{to{transform:rotate(360deg)}} +.cam-spinner{width:40px;height:40px;border:3px solid rgba(255,255,255,.15); + border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;display:none} +.cam-overlay{position:absolute;bottom:0;left:0;right:0; + background:linear-gradient(transparent,rgba(0,0,0,.75));padding:14px} +.cam-toggle{position:absolute;top:10px;right:10px;background:rgba(0,0,0,.5); + border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:8px; + padding:6px 10px;cursor:pointer;font-size:12px;backdrop-filter:blur(4px)} +.cam-toggle:hover{background:rgba(0,0,0,.7)} + +/* ── PROGRESS ── */ +.hero-info{display:flex;flex-direction:column;gap:12px} +.pct-big{font-size:52px;font-weight:700;line-height:1;color:var(--txt)} +.pct-big small{font-size:20px;font-weight:400;color:var(--txt2)} +.progress-bar{height:8px;background:var(--raised);border-radius:4px;overflow:hidden;margin:4px 0} +.progress-fill{height:100%;background:linear-gradient(90deg,var(--accent),#0080cc); + border-radius:4px;transition:width .6s ease} +.meta-row{display:flex;justify-content:space-between;font-size:12px;color:var(--txt2)} +.layer-badge{background:var(--raised);border-radius:6px;padding:4px 8px; + font-family:var(--mono);font-size:12px;color:var(--txt)} +.fname{font-size:12px;color:var(--txt2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis; + background:var(--raised);border-radius:6px;padding:6px 8px} + +/* ── PRINT CONTROLS ── */ +.ctrl-btns{display:flex;gap:8px;flex-wrap:wrap} +.btn{border:none;border-radius:8px;padding:10px 16px;font-size:13px;font-weight:600; + cursor:pointer;transition:opacity .15s,transform .1s;white-space:nowrap} +.btn:hover{opacity:.85;transform:translateY(-1px)} +.btn:active{transform:translateY(0)} +.btn-start{background:var(--ok);color:#0d2010} +.btn-pause{background:var(--raised);color:var(--txt);border:1px solid var(--border)} +.btn-resume{background:#0d2d1a;color:var(--ok);border:1px solid var(--ok)} +.btn-skip{background:var(--raised);color:var(--warn);border:1px solid var(--warn)} +.btn-cancel{background:#2d0d0d;color:var(--err);border:1px solid var(--err)} +.btn-accent{background:var(--accent);color:#001a24} +.btn-sm{padding:7px 12px;font-size:12px} +.spd-btn{flex:1;border:1.5px solid var(--border);background:var(--raised);color:var(--txt); + border-radius:10px;padding:14px 8px;font-size:13px;font-weight:600;cursor:pointer; + transition:all .15s;display:flex;flex-direction:column;align-items:center;gap:4px} +.spd-btn:hover{border-color:var(--accent);color:var(--accent)} +.spd-btn.spd-active{border-color:var(--accent);background:rgba(0,200,255,.12);color:var(--accent)} +.spd-btn .spd-icon{font-size:22px} +.spd-bar{height:4px;border-radius:2px;background:var(--border);margin-top:10px;overflow:hidden} +.spd-bar-fill{height:100%;border-radius:2px;background:linear-gradient(90deg,var(--accent2),var(--accent));transition:width .3s} + +/* ── TIME CARDS ── */ +.time-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:8px} +.time-block{background:var(--raised);border-radius:10px;padding:10px 12px} +.time-label{font-size:10px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:4px} +.time-val{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--txt)} +/* ── TEMPS ── */ +.temp-pair{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.temp-card-inner{display:grid;grid-template-columns:1fr 1fr;gap:12px} +.temp-block{background:var(--raised);border-radius:10px;padding:14px;position:relative} +.temp-label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--txt2);margin-bottom:6px} +.temp-row{display:flex;align-items:baseline;gap:6px} +.temp-val{font-size:30px;font-weight:700;font-family:var(--mono)} +.temp-unit{font-size:14px;color:var(--txt2)} +.temp-target{font-size:11px;color:var(--txt2);margin-top:2px} +.temp-arc{position:absolute;top:12px;right:12px} +.temp-edit{display:flex;gap:6px;margin-top:10px} +.temp-input{background:var(--bg);border:1px solid var(--border);color:var(--txt); + border-radius:6px;padding:5px 8px;font-size:13px;font-family:var(--mono);width:70px} +.temp-input:focus{outline:none;border-color:var(--accent)} +.chart-wrap{margin-top:12px} +canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background:var(--raised)} + +/* ── MOTION ── */ +.joypad{display:grid;grid-template-columns:repeat(3,52px); + grid-template-rows:repeat(3,52px);gap:6px;justify-content:center;margin:8px auto} +.joy{background:var(--raised);border:1px solid var(--border);color:var(--txt); + border-radius:10px;font-size:18px;cursor:pointer;transition:.12s; + display:flex;align-items:center;justify-content:center} +.joy:hover{background:var(--accent);color:#001a24;border-color:var(--accent)} +.joy:active{transform:scale(.93)} +.joy.home{font-size:14px;background:var(--bg)} +.step-btns{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-top:10px} +.step-btn{background:var(--raised);border:1px solid var(--border);color:var(--txt2); + border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer;transition:.12s} +.step-btn.active,.step-btn:hover{background:var(--accent);color:#001a24;border-color:var(--accent)} +.home-btns{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px;justify-content:center} + +/* ── AMS ── */ +.ams-slots{display:flex;flex-direction:column;gap:12px} +.ams-box-group{} +.ams-box-label{font-size:11px;font-weight:700;color:var(--txt2);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px;padding-left:2px} +.ams-box-slots{display:grid;grid-template-columns:repeat(4,1fr);gap:8px} +.ams-slot{background:var(--raised);border-radius:10px;padding:10px;text-align:center; + border:2px solid transparent;transition:.2s;position:relative} +.ams-slot.active{border-color:var(--slot-color,var(--accent)); + box-shadow:0 0 12px rgba(var(--slot-rgb,0,200,255),.3)} +.ams-slot.loaded{border-color:var(--ok)!important; + box-shadow:0 0 0 2px rgba(64,220,120,.35),0 0 14px rgba(64,220,120,.35)} +.ams-slot.loading{border-color:var(--ok)!important;animation:amsPulseGreen 1s ease-in-out infinite} +.ams-slot.unloading{border-color:var(--err)!important;animation:amsPulseRed 1s ease-in-out infinite} +@keyframes amsPulseGreen{0%{box-shadow:0 0 0 0 rgba(64,220,120,.55)}50%{box-shadow:0 0 0 4px rgba(64,220,120,.25),0 0 18px rgba(64,220,120,.45)}100%{box-shadow:0 0 0 0 rgba(64,220,120,.55)}} +@keyframes amsPulseRed{0%{box-shadow:0 0 0 0 rgba(230,80,80,.55)}50%{box-shadow:0 0 0 4px rgba(230,80,80,.25),0 0 18px rgba(230,80,80,.45)}100%{box-shadow:0 0 0 0 rgba(230,80,80,.55)}} +.ams-slot-bridge{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px; + border:1px dashed var(--border);background:linear-gradient(180deg,rgba(255,255,255,.03),rgba(255,255,255,.01)); + color:var(--txt2);min-height:106px} +.ams-slot-bridge .bridge-chip{width:58px;height:58px;border:1px solid rgba(255,255,255,.14);border-radius:50%; + display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.04);color:var(--txt2); + font-size:13px;font-weight:700;letter-spacing:.04em} +.slot-circle{width:36px;height:36px;border-radius:50%;margin:0 auto 6px;border:2px solid rgba(255,255,255,.15)} +.slot-label{font-size:11px;color:var(--txt2);font-family:var(--mono)} +.slot-material{font-size:12px;font-weight:600;margin-bottom:2px} + +/* ── LIGHT + FAN ── */ +.toggle-row{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px} +.toggle-label{font-size:13px;font-weight:600} +.toggle{position:relative;width:44px;height:24px;cursor:pointer} +.toggle input{opacity:0;width:0;height:0;position:absolute} +.toggle-track{width:44px;height:24px;background:var(--raised);border-radius:12px; + border:1px solid var(--border);transition:.25s;display:block} +.toggle input:checked+.toggle-track{background:var(--accent)} +.toggle-thumb{position:absolute;top:3px;left:3px;width:18px;height:18px; + background:#fff;border-radius:50%;transition:.25s;pointer-events:none} +.toggle input:checked~.toggle-thumb{transform:translateX(20px)} +.slider-row{display:flex;align-items:center;gap:10px;margin-top:8px} +.slider-label{font-size:12px;color:var(--txt2);width:80px} +.slider{flex:1;-webkit-appearance:none;height:4px;border-radius:2px; + background:var(--raised);outline:none;cursor:pointer} +.slider::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px; + border-radius:50%;background:var(--accent);cursor:pointer;transition:.1s} +.slider::-webkit-slider-thumb:hover{transform:scale(1.2)} +.slider-val{font-family:var(--mono);font-size:12px;color:var(--txt);width:30px;text-align:right} + +/* ── CONSOLE ── */ +.console{background:#0a0a0e;border-radius:8px;padding:10px;font-family:var(--mono); + font-size:11px;color:#8888aa;overflow-y:auto;line-height:1.6} +.console .ts{color:#444;margin-right:6px} +.console .msg-info{color:#8888aa} +.console .msg-ok{color:var(--ok)} +.console .msg-err{color:var(--err)} +.console .msg-warn{color:var(--warn)} + +/* ── PANELS ── */ +.panel{display:none} +.panel.active{display:block} + +/* ── MODAL ── */ +.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6); + z-index:200;align-items:center;justify-content:center;padding:16px} +.modal-overlay.open{display:flex} +.modal-box{background:var(--card);border:1px solid var(--border);border-radius:14px; + width:100%;max-width:480px;max-height:90vh;overflow-y:auto;padding:24px; + display:flex;flex-direction:column;gap:18px} +.modal-header{display:flex;align-items:center;justify-content:space-between} +.modal-title{font-size:15px;font-weight:700;color:var(--txt)} +.modal-close{background:none;border:none;color:var(--txt2);font-size:20px; + cursor:pointer;padding:4px 8px;border-radius:6px} +.modal-close:hover{background:var(--raised);color:var(--txt)} +.modal-section{font-size:10px;text-transform:uppercase;letter-spacing:.1em; + color:var(--txt2);margin-bottom:6px;margin-top:4px} +.modal-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px} +.modal-field label{font-size:12px;color:var(--txt2)} +.modal-field input{background:var(--raised);border:1px solid var(--border); + border-radius:7px;color:var(--txt);padding:7px 10px;font-size:13px;width:100%} +.modal-field input:focus{outline:none;border-color:var(--accent)} +.poll-btns{display:flex;gap:8px} +.poll-btn{flex:1;padding:7px;background:var(--raised);border:1px solid var(--border); + border-radius:7px;color:var(--txt2);cursor:pointer;font-size:13px;transition:all .15s} +.poll-btn.active{background:var(--accent);border-color:var(--accent);color:#000;font-weight:600} +.update-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.update-status{font-size:12px;color:var(--txt2);flex:1;min-width:0} +.modal-save{width:100%;padding:10px;background:var(--accent);border:none; + border-radius:8px;color:#000;font-weight:700;font-size:14px;cursor:pointer;margin-top:4px} +.modal-save:hover{opacity:.88} + +/* ── BOTTOM NAV (mobile) ── */ +nav.bottom-nav{display:none;position:fixed;bottom:0;left:0;right:0; + background:var(--card);border-top:1px solid var(--border); + justify-content:space-around;padding:8px 0 max(8px,env(safe-area-inset-bottom))} +.bnav-btn{background:none;border:none;color:var(--txt2);display:flex; + flex-direction:column;align-items:center;gap:3px;cursor:pointer;font-size:10px;padding:4px 8px} +.bnav-btn.active{color:var(--accent)} +.bnav-icon{font-size:20px} + +/* ── Tablet (769–1100px): schmale Sidebar ── */ +@media(min-width:769px) and (max-width:1100px){ + nav.sidebar{width:52px;padding:12px 4px} + .nav-btn .nav-text{display:none} + .nav-btn{justify-content:center;padding:10px} + .nav-icon{width:auto} + .grid{grid-template-columns:repeat(2,1fr)} + .hero{grid-template-columns:1fr} +} + +/* ── Mobile (≤768px): Bottom-Nav, 1-Spalte ── */ +@media(max-width:768px){ + nav.sidebar{display:none} + nav.bottom-nav{display:flex} + main{padding:10px;padding-bottom:72px} + + /* Header kompakt */ + header{padding:0 12px;gap:8px} + .hname{display:none} + + /* 1-Spalten-Grid, full-width spans funktionieren weiterhin */ + .grid{grid-template-columns:1fr;gap:12px} + + /* Hero: Kamera über Info */ + .hero{grid-template-columns:1fr} + .cam-wrap{max-height:220px} + + /* Temp-Pair und Temp-Card übereinander */ + .temp-pair{grid-template-columns:1fr} + .temp-card-inner{grid-template-columns:1fr} + + /* AMS: 2 Spalten auf kleinen Screens */ + .ams-box-slots{grid-template-columns:repeat(2,1fr)} + + /* Joypad etwas kleiner */ + .joypad{grid-template-columns:repeat(3,44px);grid-template-rows:repeat(3,44px);gap:5px} + .joy{font-size:16px} + + /* Buttons größere Touch-Targets */ + .btn{padding:10px 14px;font-size:13px} + .btn-sm{padding:8px 12px} + .step-btn{padding:8px 12px;font-size:13px} + + /* Modal vollbreite auf kleinen Screens */ + .modal-box{padding:16px;border-radius:10px} + .poll-btns{gap:6px} +}