From 9c2822f36744da9111b7b5a00bfdd30eca775128 Mon Sep 17 00:00:00 2001 From: Knutwurst <36196269+knutwurst@users.noreply.github.com> Date: Tue, 23 Jun 2026 07:52:17 +0200 Subject: [PATCH] Initial PatchDL web UI --- README.md | 23 ++ web/README.md | 47 ++++ web/app.js | 483 +++++++++++++++++++++++++++++++++ web/index.html | 210 +++++++++++++++ web/styles.css | 715 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1478 insertions(+) create mode 100644 README.md create mode 100644 web/README.md create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/styles.css 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 + + + + + +
+ + +
+
+
+

Standalone ELF Web UI

+

Spielupdates kontrolliert laden

+
+
+ + +
+
+ +
+
+ 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; + } +}