commit 9c2822f36744da9111b7b5a00bfdd30eca775128
Author: Knutwurst <36196269+knutwurst@users.noreply.github.com>
Date: Tue Jun 23 07:52:17 2026 +0200
Initial PatchDL web UI
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4a2556c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# PatchDL
+
+PatchDL is a planned standalone PS5 ELF payload for controlled official game
+patch downloads.
+
+The target design is deny-by-default:
+
+- Sony domains stay blocked by nanoDNS for the normal PS5 system.
+- PatchDL uses its own internal allowlist resolver for Sony CDN hosts.
+- Games must be explicitly enabled before PatchDL checks or downloads updates.
+- Patch selection is capped to the highest update compatible with the current
+ firmware.
+
+## Current Contents
+
+```text
+web/
+ Static web UI prototype for the future embedded PatchDL web server.
+```
+
+Open `web/index.html` directly for the mock UI, or serve `web/` from a local
+HTTP server.
+
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..b452759
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,47 @@
+# PatchDL Web UI
+
+Standalone static UI for the planned PS5 `patchdl.elf` web server.
+
+Open `index.html` directly for the mock UI, or serve this directory from the
+ELF web server. The JavaScript first tries the real API and falls back to demo
+data when the API is not available.
+
+## Expected API
+
+```text
+GET /api/status
+GET /api/config
+POST /api/config
+GET /api/titles
+GET /api/downloads
+POST /api/titles/:title_id/check
+POST /api/titles/:title_id/download
+```
+
+## Policy Model
+
+The UI assumes deny-by-default behavior:
+
+```json
+{
+ "default_policy": "deny",
+ "download_dir": "/mnt/usb0/patches",
+ "install_after_download": false,
+ "delete_pkg_after_install": false,
+ "cdn_allowlist": [
+ "sgst.prod.dl.playstation.net",
+ "gst.prod.dl.playstation.net",
+ "gs2.ww.prod.dl.playstation.net"
+ ]
+}
+```
+
+Per-title modes:
+
+```text
+disabled
+download_only
+latest_compatible
+pin
+check_only
+```
diff --git a/web/app.js b/web/app.js
new file mode 100644
index 0000000..44e8286
--- /dev/null
+++ b/web/app.js
@@ -0,0 +1,483 @@
+const API = {
+ status: "/api/status",
+ titles: "/api/titles",
+ config: "/api/config",
+ downloads: "/api/downloads",
+ action: (titleId, action) => `/api/titles/${encodeURIComponent(titleId)}/${action}`,
+};
+
+const fallback = {
+ status: {
+ firmware: "11.60",
+ firmware_build: "0x11600000",
+ dns_guard: "Aktiv",
+ resolver: "Internal allowlist",
+ free_space_mb: 64218,
+ download_dir: "/mnt/usb0/patches",
+ },
+ config: {
+ default_policy: "deny",
+ download_dir: "/mnt/usb0/patches",
+ install_after_download: false,
+ delete_pkg_after_install: false,
+ cdn_allowlist: [
+ "sgst.prod.dl.playstation.net",
+ "gst.prod.dl.playstation.net",
+ "gs2.ww.prod.dl.playstation.net",
+ ],
+ },
+ titles: [
+ {
+ title_id: "PPSA01628_00",
+ name: "Call of Duty Black Ops Cold War",
+ content_id: "UP0002-PPSA01628_00-CODCWTHEGAME0001",
+ installed_version: "01.032.000",
+ compatible_version: "01.041.000",
+ latest_version: "01.041.000",
+ latest_required_fw: "11.60",
+ enabled: true,
+ mode: "latest_compatible",
+ queued: false,
+ status: "available",
+ },
+ {
+ title_id: "PPSA01284_00",
+ name: "Demon's Souls",
+ content_id: "UP9000-PPSA01284_00-DEMONSSOULS00001",
+ installed_version: "01.002.000",
+ compatible_version: "01.004.000",
+ latest_version: "01.004.000",
+ latest_required_fw: "10.01",
+ enabled: true,
+ mode: "pin",
+ max_content_ver: "01.004.000",
+ queued: true,
+ status: "queued",
+ },
+ {
+ title_id: "PPSA08329_00",
+ name: "Example Future Game",
+ content_id: "EP0000-PPSA08329_00-FUTUREPATCH00001",
+ installed_version: "01.000.000",
+ compatible_version: null,
+ latest_version: "01.012.000",
+ latest_required_fw: "12.50",
+ enabled: false,
+ mode: "disabled",
+ queued: false,
+ status: "blocked",
+ },
+ ],
+ downloads: [
+ {
+ title_id: "PPSA01284_00",
+ name: "Demon's Souls",
+ version: "01.004.000",
+ progress: 64,
+ detail: "4.8 GB von 7.5 GB",
+ },
+ ],
+ logs: [
+ "[12:41:02] nanoDNS guard detected: Sony domains blocked",
+ "[12:41:04] Internal resolver ready for 3 CDN hosts",
+ "[12:41:07] PPSA01284_00 selected: pin 01.004.000",
+ "[12:41:09] Download started: PPSA01284_00 01.004.000",
+ ],
+};
+
+let state = {
+ status: fallback.status,
+ config: fallback.config,
+ titles: fallback.titles,
+ downloads: fallback.downloads,
+ logs: fallback.logs,
+ filter: "all",
+ query: "",
+ usingFallback: false,
+};
+
+const els = {};
+
+document.addEventListener("DOMContentLoaded", () => {
+ bindElements();
+ bindEvents();
+ loadInitialData();
+});
+
+function bindElements() {
+ Object.assign(els, {
+ firmwareValue: document.getElementById("firmwareValue"),
+ firmwareBuild: document.getElementById("firmwareBuild"),
+ dnsValue: document.getElementById("dnsValue"),
+ resolverValue: document.getElementById("resolverValue"),
+ spaceValue: document.getElementById("spaceValue"),
+ downloadDirValue: document.getElementById("downloadDirValue"),
+ gameGrid: document.getElementById("gameGrid"),
+ queueList: document.getElementById("queueList"),
+ logOutput: document.getElementById("logOutput"),
+ allowlistHosts: document.getElementById("allowlistHosts"),
+ searchInput: document.getElementById("searchInput"),
+ defaultPolicy: document.getElementById("defaultPolicy"),
+ downloadDir: document.getElementById("downloadDir"),
+ installAfterDownload: document.getElementById("installAfterDownload"),
+ deleteAfterInstall: document.getElementById("deleteAfterInstall"),
+ refreshBtn: document.getElementById("refreshBtn"),
+ saveBtn: document.getElementById("saveBtn"),
+ pauseAllBtn: document.getElementById("pauseAllBtn"),
+ toast: document.getElementById("toast"),
+ });
+}
+
+function bindEvents() {
+ els.searchInput.addEventListener("input", (event) => {
+ state.query = event.target.value.trim().toLowerCase();
+ renderGames();
+ });
+
+ document.querySelectorAll(".segmented button").forEach((button) => {
+ button.addEventListener("click", () => {
+ document.querySelectorAll(".segmented button").forEach((item) => item.classList.remove("is-selected"));
+ button.classList.add("is-selected");
+ state.filter = button.dataset.filter;
+ renderGames();
+ });
+ });
+
+ els.refreshBtn.addEventListener("click", loadInitialData);
+ els.saveBtn.addEventListener("click", saveConfig);
+ els.pauseAllBtn.addEventListener("click", () => showToast("Queue wurde pausiert."));
+}
+
+async function loadInitialData() {
+ const [status, config, titles, downloads] = await Promise.all([
+ getJson(API.status, fallback.status),
+ getJson(API.config, fallback.config),
+ getJson(API.titles, fallback.titles),
+ getJson(API.downloads, fallback.downloads),
+ ]);
+
+ state = {
+ ...state,
+ status,
+ config,
+ titles,
+ downloads,
+ };
+
+ render();
+ showToast(state.usingFallback ? "Demo-Daten geladen. API noch nicht erreichbar." : "Daten aktualisiert.");
+}
+
+async function getJson(url, fallbackValue) {
+ try {
+ const response = await fetch(url, { cache: "no-store" });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return await response.json();
+ } catch (error) {
+ state.usingFallback = true;
+ return clone(fallbackValue);
+ }
+}
+
+function render() {
+ renderStatus();
+ renderSettings();
+ renderGames();
+ renderQueue();
+ renderLogs();
+}
+
+function renderStatus() {
+ els.firmwareValue.textContent = state.status.firmware || "--";
+ els.firmwareBuild.textContent = state.status.firmware_build || "Systemversion";
+ els.dnsValue.textContent = state.status.dns_guard || "--";
+ els.resolverValue.textContent = state.status.resolver || "--";
+ els.spaceValue.textContent = formatMb(state.status.free_space_mb);
+ els.downloadDirValue.textContent = state.status.download_dir || state.config.download_dir || "Download Ziel";
+}
+
+function renderSettings() {
+ els.defaultPolicy.value = state.config.default_policy || "deny";
+ els.downloadDir.value = state.config.download_dir || "";
+ els.installAfterDownload.checked = Boolean(state.config.install_after_download);
+ els.deleteAfterInstall.checked = Boolean(state.config.delete_pkg_after_install);
+ els.allowlistHosts.replaceChildren(...(state.config.cdn_allowlist || []).map((host) => {
+ const chip = document.createElement("span");
+ chip.className = "host-chip";
+ chip.textContent = host;
+ return chip;
+ }));
+}
+
+function renderGames() {
+ const visible = state.titles.filter(matchesFilter).filter(matchesQuery);
+ els.gameGrid.replaceChildren();
+
+ if (!visible.length) {
+ const empty = document.createElement("div");
+ empty.className = "empty-state";
+ empty.textContent = "Keine Titel fuer diesen Filter.";
+ els.gameGrid.appendChild(empty);
+ return;
+ }
+
+ visible.forEach((game) => els.gameGrid.appendChild(createGameCard(game)));
+}
+
+function matchesFilter(game) {
+ if (state.filter === "enabled") return game.enabled;
+ if (state.filter === "available") return game.status === "available";
+ if (state.filter === "blocked") return game.status === "blocked";
+ if (state.filter === "queued") return game.queued || game.status === "queued";
+ return true;
+}
+
+function matchesQuery(game) {
+ if (!state.query) return true;
+ return [game.name, game.title_id, game.content_id]
+ .filter(Boolean)
+ .some((value) => value.toLowerCase().includes(state.query));
+}
+
+function createGameCard(game) {
+ const card = document.createElement("article");
+ card.className = "game-card";
+
+ const title = document.createElement("div");
+ title.className = "game-title";
+ title.innerHTML = `
+
${initials(game.name)}
+
+
${escapeHtml(game.name)}
+
+ ${escapeHtml(game.title_id)}
+ ${escapeHtml(game.content_id || "")}
+
+
+ ${statusPill(game)}
+ ${game.enabled ? "Aktiv" : "Aus"}
+
+
+ `;
+
+ const versions = document.createElement("div");
+ versions.className = "version-grid";
+ versions.innerHTML = `
+ ${versionBox("Installiert", game.installed_version || "--")}
+ ${versionBox("Kompatibel", game.compatible_version || "Keine")}
+ ${versionBox("Neueste", `${game.latest_version || "--"} / FW ${game.latest_required_fw || "--"}`)}
+ `;
+
+ const controls = document.createElement("div");
+ controls.className = "controls";
+
+ const controlRow = document.createElement("div");
+ controlRow.className = "control-row";
+
+ const mode = document.createElement("select");
+ mode.title = "Update-Modus";
+ [
+ ["disabled", "Disabled"],
+ ["download_only", "Download only"],
+ ["latest_compatible", "Latest compatible"],
+ ["pin", "Pin version"],
+ ["check_only", "Check only"],
+ ].forEach(([value, label]) => {
+ const option = document.createElement("option");
+ option.value = value;
+ option.textContent = label;
+ option.selected = (game.mode || "disabled") === value;
+ mode.appendChild(option);
+ });
+ mode.addEventListener("change", () => updateGame(game.title_id, { mode: mode.value, enabled: mode.value !== "disabled" }));
+
+ const pin = document.createElement("input");
+ pin.title = "Maximale Content-Version";
+ pin.placeholder = "max ver";
+ pin.value = game.max_content_ver || "";
+ pin.addEventListener("change", () => updateGame(game.title_id, { max_content_ver: pin.value.trim() }));
+
+ controlRow.append(mode, pin);
+
+ const actions = document.createElement("div");
+ actions.className = "actions-row";
+ actions.innerHTML = `
+
+
+ `;
+
+ actions.querySelectorAll("button").forEach((button) => {
+ button.addEventListener("click", () => runTitleAction(game.title_id, button.dataset.action));
+ });
+
+ controls.append(controlRow, actions);
+ card.append(title, versions, controls);
+ return card;
+}
+
+function versionBox(label, value) {
+ return `
+
+ ${escapeHtml(label)}
+ ${escapeHtml(value)}
+
+ `;
+}
+
+function statusPill(game) {
+ if (game.status === "blocked") return `FW blockiert`;
+ if (game.status === "queued") return `In Queue`;
+ if (game.status === "available") return `Update verfuegbar`;
+ return `Aktuell`;
+}
+
+function renderQueue() {
+ els.queueList.replaceChildren();
+ if (!state.downloads.length) {
+ const empty = document.createElement("div");
+ empty.className = "empty-state";
+ empty.textContent = "Keine aktiven Downloads.";
+ els.queueList.appendChild(empty);
+ return;
+ }
+
+ state.downloads.forEach((item) => {
+ const row = document.createElement("div");
+ row.className = "queue-item";
+ row.innerHTML = `
+
+
${escapeHtml(item.name)}
+
${escapeHtml(item.title_id)} - ${escapeHtml(item.version)} - ${escapeHtml(item.detail || "")}
+
+
+ ${item.progress || 0}%
+ `;
+ els.queueList.appendChild(row);
+ });
+}
+
+function renderLogs() {
+ els.logOutput.textContent = state.logs.join("\n");
+}
+
+async function saveConfig() {
+ const config = {
+ ...state.config,
+ default_policy: els.defaultPolicy.value,
+ download_dir: els.downloadDir.value.trim(),
+ install_after_download: els.installAfterDownload.checked,
+ delete_pkg_after_install: els.deleteAfterInstall.checked,
+ };
+
+ try {
+ await postJson(API.config, config);
+ state.config = config;
+ showToast("Konfiguration gespeichert.");
+ } catch (error) {
+ state.config = config;
+ showToast("Konfiguration lokal aktualisiert. API noch nicht erreichbar.");
+ }
+
+ renderSettings();
+}
+
+async function runTitleAction(titleId, action) {
+ const game = state.titles.find((item) => item.title_id === titleId);
+ if (!game) return;
+
+ try {
+ await postJson(API.action(titleId, action), {});
+ } catch (error) {
+ // Demo mode keeps the UI interactive before the ELF API exists.
+ }
+
+ if (action === "download" && game.compatible_version) {
+ game.queued = true;
+ game.status = "queued";
+ if (!state.downloads.some((item) => item.title_id === titleId)) {
+ state.downloads.push({
+ title_id: game.title_id,
+ name: game.name,
+ version: game.compatible_version,
+ progress: 0,
+ detail: "Wartet auf Start",
+ });
+ }
+ state.logs.push(`[${timeNow()}] Queued ${game.title_id} ${game.compatible_version}`);
+ showToast(`${game.title_id} wurde zur Queue hinzugefuegt.`);
+ } else {
+ state.logs.push(`[${timeNow()}] Check requested for ${game.title_id}`);
+ showToast(`${game.title_id} wird geprueft.`);
+ }
+
+ renderGames();
+ renderQueue();
+ renderLogs();
+}
+
+function updateGame(titleId, patch) {
+ const game = state.titles.find((item) => item.title_id === titleId);
+ if (!game) return;
+ Object.assign(game, patch);
+ if (patch.mode === "disabled") game.enabled = false;
+ state.logs.push(`[${timeNow()}] Policy updated for ${titleId}`);
+ renderGames();
+ renderLogs();
+}
+
+async function postJson(url, body) {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ return response.json().catch(() => ({}));
+}
+
+function initials(name) {
+ return (name || "PD")
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0])
+ .join("")
+ .toUpperCase();
+}
+
+function formatMb(value) {
+ if (!Number.isFinite(Number(value))) return "--";
+ if (value > 1024) return `${(value / 1024).toFixed(1)} GB`;
+ return `${value} MB`;
+}
+
+function escapeHtml(value) {
+ return String(value ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+function clone(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+function timeNow() {
+ return new Date().toLocaleTimeString("de-DE", { hour12: false });
+}
+
+let toastTimer;
+function showToast(message) {
+ els.toast.textContent = message;
+ els.toast.classList.add("is-visible");
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 2600);
+}
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..b7b80fa
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,210 @@
+
+
+
+
+
+ PatchDL
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Firmware
+ --
+ Systemversion
+
+
+ DNS Guard
+ --
+ Sony blockiert per nanoDNS
+
+
+ CDN Zugriff
+ --
+ Nur PatchDL Resolver
+
+
+ Speicher
+ --
+ Download Ziel
+
+
+
+
+
+
+
+
+
+
+
+
Downloads
+
Aktive Queue
+
+
+
+
+
+
+
+
+
+
Policy
+
Update Regeln
+
+
+
+
+
+
+
+
+
+
+
+
Interne CDN Allowlist
+
+
+
+
+
+
+
+
+
Runtime
+
Letzte Ereignisse
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/styles.css b/web/styles.css
new file mode 100644
index 0000000..4ee9c0f
--- /dev/null
+++ b/web/styles.css
@@ -0,0 +1,715 @@
+:root {
+ color-scheme: light;
+ --bg: #f6f7f4;
+ --surface: #ffffff;
+ --surface-2: #eeefe9;
+ --ink: #1f2420;
+ --muted: #697069;
+ --border: #d7dbd2;
+ --green: #247857;
+ --green-dark: #155f42;
+ --green-soft: #dff3e9;
+ --amber: #b87516;
+ --amber-soft: #fff1d6;
+ --red: #b54343;
+ --red-soft: #ffe4e4;
+ --blue: #2d6d9d;
+ --blue-soft: #deeff8;
+ --shadow: 0 18px 50px rgba(31, 36, 32, 0.1);
+ --radius: 8px;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ color: var(--ink);
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+svg {
+ width: 18px;
+ height: 18px;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.9;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.icon-sprite {
+ position: absolute;
+ width: 0;
+ height: 0;
+ overflow: hidden;
+}
+
+.app-shell {
+ display: grid;
+ grid-template-columns: 248px minmax(0, 1fr);
+ min-height: 100vh;
+}
+
+.rail {
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ padding: 20px 16px;
+ background: #1e241f;
+ color: #f7f8f4;
+ border-right: 1px solid #111611;
+}
+
+.brand {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-height: 44px;
+ margin-bottom: 28px;
+}
+
+.brand-mark {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ background: #2b6f50;
+ border-radius: 8px;
+ font-weight: 800;
+}
+
+.brand strong {
+ display: block;
+ font-size: 16px;
+}
+
+.brand span {
+ display: block;
+ margin-top: 2px;
+ color: #b8c2ba;
+ font-size: 12px;
+}
+
+.nav-list {
+ display: grid;
+ gap: 6px;
+}
+
+.nav-link {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 40px;
+ padding: 0 10px;
+ color: #cbd3cc;
+ text-decoration: none;
+ border-radius: 8px;
+}
+
+.nav-link:hover,
+.nav-link.is-active {
+ background: rgba(255, 255, 255, 0.09);
+ color: #ffffff;
+}
+
+.main {
+ min-width: 0;
+ padding: 28px;
+}
+
+.topbar {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 22px;
+}
+
+.eyebrow {
+ margin: 0 0 6px;
+ color: var(--green-dark);
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0;
+}
+
+h1,
+h2 {
+ margin: 0;
+ letter-spacing: 0;
+}
+
+h1 {
+ font-size: 30px;
+ line-height: 1.12;
+}
+
+h2 {
+ font-size: 18px;
+ line-height: 1.2;
+}
+
+.topbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.icon-button,
+.primary-button,
+.ghost-button,
+.row-button,
+.mini-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-height: 38px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--ink);
+ border-radius: 8px;
+ font-weight: 700;
+}
+
+.icon-button {
+ width: 38px;
+ padding: 0;
+}
+
+.primary-button {
+ padding: 0 14px;
+ border-color: var(--green-dark);
+ background: var(--green);
+ color: #ffffff;
+}
+
+.ghost-button,
+.row-button,
+.mini-button {
+ padding: 0 12px;
+}
+
+.mini-button {
+ min-height: 32px;
+ font-size: 13px;
+}
+
+.row-button.is-primary {
+ border-color: var(--green);
+ color: var(--green-dark);
+ background: var(--green-soft);
+}
+
+.status-strip {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 18px;
+}
+
+.metric {
+ min-width: 0;
+ padding: 16px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ border-radius: var(--radius);
+ box-shadow: 0 1px 0 rgba(31, 36, 32, 0.04);
+}
+
+.metric span,
+.metric em {
+ display: block;
+ color: var(--muted);
+ font-size: 12px;
+ font-style: normal;
+}
+
+.metric strong {
+ display: block;
+ margin: 7px 0 3px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 21px;
+}
+
+.toolbar {
+ display: grid;
+ grid-template-columns: minmax(260px, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ margin-bottom: 14px;
+}
+
+.search-box {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 42px;
+ padding: 0 12px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ border-radius: 8px;
+}
+
+.search-box input {
+ width: 100%;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--ink);
+}
+
+.segmented {
+ display: flex;
+ padding: 4px;
+ border: 1px solid var(--border);
+ background: var(--surface-2);
+ border-radius: 8px;
+}
+
+.segmented button {
+ min-height: 32px;
+ padding: 0 12px;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ border-radius: 6px;
+ font-weight: 700;
+}
+
+.segmented button.is-selected {
+ background: var(--surface);
+ color: var(--ink);
+ box-shadow: 0 1px 2px rgba(31, 36, 32, 0.08);
+}
+
+.game-grid {
+ display: grid;
+ gap: 12px;
+}
+
+.game-card {
+ display: grid;
+ grid-template-columns: minmax(250px, 1.2fr) minmax(380px, 1.6fr) minmax(260px, 0.9fr);
+ gap: 16px;
+ align-items: center;
+ padding: 16px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ border-radius: var(--radius);
+ box-shadow: 0 1px 0 rgba(31, 36, 32, 0.04);
+}
+
+.game-title {
+ display: flex;
+ gap: 12px;
+ min-width: 0;
+}
+
+.cover {
+ display: grid;
+ flex: 0 0 46px;
+ place-items: center;
+ width: 46px;
+ height: 46px;
+ color: #ffffff;
+ background: linear-gradient(135deg, #2d6d9d, #247857);
+ border-radius: 8px;
+ font-size: 13px;
+ font-weight: 800;
+}
+
+.game-title h3 {
+ margin: 0 0 5px;
+ overflow-wrap: anywhere;
+ font-size: 16px;
+ letter-spacing: 0;
+}
+
+.subline {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.pill {
+ display: inline-flex;
+ align-items: center;
+ min-height: 24px;
+ padding: 0 8px;
+ border: 1px solid var(--border);
+ background: var(--surface-2);
+ color: var(--muted);
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.pill.ok {
+ border-color: #b7dec9;
+ background: var(--green-soft);
+ color: var(--green-dark);
+}
+
+.pill.warn {
+ border-color: #f0cd91;
+ background: var(--amber-soft);
+ color: #81500b;
+}
+
+.pill.blocked {
+ border-color: #f0b8b8;
+ background: var(--red-soft);
+ color: var(--red);
+}
+
+.version-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 8px;
+}
+
+.version-box {
+ min-width: 0;
+ padding: 10px;
+ background: #f8f9f6;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.version-box span {
+ display: block;
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.version-box strong {
+ display: block;
+ margin-top: 4px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 14px;
+}
+
+.controls {
+ display: grid;
+ gap: 10px;
+}
+
+.control-row {
+ display: grid;
+ grid-template-columns: minmax(160px, 1fr) minmax(86px, 0.65fr);
+ gap: 8px;
+}
+
+.control-row select,
+.control-row input {
+ min-width: 0;
+ min-height: 36px;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--ink);
+ border-radius: 8px;
+}
+
+.actions-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.split-band {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(340px, 0.82fr);
+ gap: 12px;
+ margin-top: 22px;
+}
+
+.panel,
+.log-panel {
+ padding: 16px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ border-radius: var(--radius);
+}
+
+.panel-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.queue-list {
+ display: grid;
+ gap: 10px;
+}
+
+.queue-item {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 92px;
+ gap: 12px;
+ align-items: center;
+ padding: 12px;
+ background: #f8f9f6;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+}
+
+.queue-item strong {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 14px;
+}
+
+.queue-item span {
+ display: block;
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.progress {
+ height: 8px;
+ margin-top: 10px;
+ overflow: hidden;
+ background: #e3e6de;
+ border-radius: 999px;
+}
+
+.progress > i {
+ display: block;
+ width: 0%;
+ height: 100%;
+ background: linear-gradient(90deg, var(--green), var(--blue));
+}
+
+.settings-grid {
+ display: grid;
+ gap: 12px;
+}
+
+.field {
+ display: grid;
+ gap: 6px;
+}
+
+.field span,
+.allowlist > span {
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.field input,
+.field select {
+ min-height: 38px;
+ padding: 0 10px;
+ border: 1px solid var(--border);
+ background: #f8f9f6;
+ color: var(--ink);
+ border-radius: 8px;
+}
+
+.switch-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ padding: 10px;
+ border: 1px solid var(--border);
+ background: #f8f9f6;
+ border-radius: 8px;
+}
+
+.switch-row input {
+ width: 18px;
+ height: 18px;
+ margin-top: 2px;
+ accent-color: var(--green);
+}
+
+.switch-row strong,
+.switch-row em {
+ display: block;
+}
+
+.switch-row strong {
+ font-size: 14px;
+}
+
+.switch-row em {
+ margin-top: 2px;
+ color: var(--muted);
+ font-size: 12px;
+ font-style: normal;
+}
+
+.allowlist {
+ display: grid;
+ gap: 8px;
+ margin-top: 14px;
+}
+
+.allowlist div {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.host-chip {
+ display: inline-flex;
+ align-items: center;
+ min-height: 26px;
+ padding: 0 8px;
+ background: var(--blue-soft);
+ color: #1e547a;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.log-panel {
+ margin-top: 12px;
+}
+
+pre {
+ min-height: 150px;
+ max-height: 280px;
+ margin: 0;
+ padding: 12px;
+ overflow: auto;
+ background: #1f2420;
+ color: #e9eee8;
+ border-radius: 8px;
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.empty-state {
+ padding: 18px;
+ border: 1px dashed var(--border);
+ background: var(--surface);
+ color: var(--muted);
+ border-radius: 8px;
+ text-align: center;
+}
+
+.toast {
+ position: fixed;
+ right: 18px;
+ bottom: 18px;
+ z-index: 10;
+ max-width: min(420px, calc(100vw - 36px));
+ padding: 12px 14px;
+ color: #ffffff;
+ background: #1f2420;
+ border-radius: 8px;
+ box-shadow: var(--shadow);
+ opacity: 0;
+ transform: translateY(8px);
+ pointer-events: none;
+ transition: opacity 160ms ease, transform 160ms ease;
+}
+
+.toast.is-visible {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+@media (max-width: 1180px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .rail {
+ position: static;
+ height: auto;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .brand {
+ margin: 0;
+ }
+
+ .nav-list {
+ display: flex;
+ flex-wrap: wrap;
+ }
+
+ .game-card {
+ grid-template-columns: 1fr;
+ }
+
+ .split-band,
+ .status-strip {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 760px) {
+ .main {
+ padding: 18px;
+ }
+
+ .rail {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ .topbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .topbar-actions {
+ justify-content: flex-start;
+ }
+
+ h1 {
+ font-size: 24px;
+ }
+
+ .toolbar,
+ .split-band,
+ .status-strip,
+ .version-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .segmented {
+ overflow-x: auto;
+ }
+
+ .segmented button {
+ flex: 0 0 auto;
+ }
+}