diff --git a/docs/host-endpoint-architecture.md b/docs/host-endpoint-architecture.md
new file mode 100644
index 0000000..77612c2
--- /dev/null
+++ b/docs/host-endpoint-architecture.md
@@ -0,0 +1,165 @@
+# VibeDeck Host 後端架構圖(Startup 管線 + 端點群 + 授權)
+
+盤點日期:2026-07-22
+基準版本:`refactor/startup-endpoint-groups`(源自 `master` / 0.1.36)
+
+這份文件把 `PhoneMonitor.Host` 的 HTTP/WebSocket 後端講清楚:**中介軟體管線順序**、**端點依責任分成哪些群(各自一個 `Startup.*.cs` partial)**、**每群呼叫哪些服務**、以及**每個端點套哪一層授權 gate**。目的是消除「所有東西擠在 `Startup.cs`」的義大利麵,讓後續改動能看圖定位。
+
+`Startup` 是一個 `partial class`,拆成多個檔案,但編譯期仍是同一個型別;共用的私有 helper(`Require*`、`WriteAudit`、`SocketJsonOptions`、`ParseInt`、各種 `Write*Async`)留在 `Startup.cs`,各 partial 直接呼叫。
+
+---
+
+## 1. 中介軟體管線(請求進來的固定順序)
+
+順序就是安全契約,不能隨意調換。定義在 `Startup.Configure`。
+
+```mermaid
+flowchart TD
+ REQ["HTTP / WebSocket 請求"] --> DEV{"env.IsDevelopment?"}
+ DEV -- 是 --> DEP["UseDeveloperExceptionPage(僅開發)"]
+ DEV -- 否 --> FWD
+ DEP --> FWD["UseForwardedHeaders
還原反向代理後的真實 IP / Proto"]
+ FWD --> ROUTE["UseRouting"]
+ ROUTE --> WSK["UseWebSockets"]
+ WSK --> STATIC["UseStaticFiles
服務 wwwroot;index/js/css/json 強制 no-store"]
+ STATIC --> EP["UseEndpoints
註冊所有端點群"]
+ EP --> GATE["每個端點在委派內自帶授權 gate
(見第 3 節)"]
+```
+
+重點:授權**不是**一層全域 middleware,而是每個端點在自己的處理委派最前面呼叫 `Require*Async` gate。所以「哪個端點受哪種保護」要看端點本身,這也是為什麼把端點依 gate 分群後會清爽很多。
+
+---
+
+## 2. 端點群 → partial 檔 → 服務
+
+`UseEndpoints` 內先呼叫各群的 `Map*Endpoints(endpoints)`,再保留安全垂直等仍內聯的端點。
+
+```mermaid
+flowchart LR
+ subgraph EXTRACTED["已外移的端點群(各自一個 Startup.*.cs)"]
+ SYS["MapSystemEndpoints
Startup.System.cs"]
+ ONB["MapOnboardingAssetEndpoints
Startup.OnboardingAssets.cs"]
+ DISP["MapDisplayEndpoints
Startup.Display.cs"]
+ SIDE["MapSideboardEndpoints
Startup.Sideboard.cs"]
+ QUOTA["MapQuotaEndpoints
Startup.Quotas.cs"]
+ STREAMEP["MapStreamingEndpoints
Startup.Streaming.cs"]
+ TRANS["MapStreamTransportEndpoints
Startup.StreamTransport.cs"]
+ CUSTOM["MapCustomSourceEndpoints
Startup.CustomSources.cs"]
+ DASHL["MapDashboardLayoutEndpoints
Startup.DashboardLayout.cs"]
+ DIAG["MapDiagnosticsEndpoints
Startup.Diagnostics.cs"]
+ UPD["MapProductUpdateEndpoints
Startup.ProductUpdates.cs"]
+ NOTE["MapWindowsNotificationEndpoints
Startup.WindowsNotifications.cs"]
+ end
+
+ subgraph CORE["仍內聯在 Startup.cs 的核心(本波刻意不動)"]
+ AUTH["Auth /api/auth/*"]
+ CONN["Connect /api/connect*"]
+ SESS["Session /api/session"]
+ DEVP["Devices + Pairing /api/devices/*"]
+ SSE["Dashboard SSE /api/dashboard/events"]
+ ROOT["Root / (index.html + AGY OAuth callback)"]
+ end
+
+ SYS --> SVC_STREAMCAP["H264AnnexBStreamer / WebRtcH264Service(能力查詢)"]
+ ONB --> SVC_CONN["ConnectInfoProvider"]
+ ONB --> SVC_CERT["LocalHttpsCertificate"]
+ DISP --> SVC_DISP["DisplayCatalog / DeckWindowLauncher / DisplayModeController / VirtualDisplayController / VirtualDisplayInstaller"]
+ SIDE --> SVC_GLANCE["GlanceBoardProxy"]
+ QUOTA --> SVC_QUOTA["AiQuotaService"]
+ STREAMEP --> SVC_WS["DisplayFrameSource / WebRtcH264Service / WindowsInputController"]
+ CUSTOM --> SVC_CUSTOM["CustomSourceService / CustomSourceStore"]
+ DASHL --> SVC_DASHL["DashboardLayoutService"]
+
+ AUTH --> SVC_AUTH["HostAccessAuthService"]
+ CONN --> SVC_CONNSVC["ConnectInfoProvider / PublicEndpointService / ConnectionCodeBrokerService"]
+ SESS --> SVC_SESS["ActionTokenService + DeviceTrustService + HostAccessAuthService"]
+ DEVP --> SVC_DEV["DeviceTrustService"]
+ SSE --> SVC_SSE["DashboardEventHub"]
+```
+
+### 端點對照表
+
+| 群 / partial | 代表端點 | 主要服務 | 授權 gate |
+|---|---|---|---|
+| `MapSystemEndpoints` | `/health`, `/api/stream/capabilities` | (能力查詢用 `H264AnnexBStreamer`/`WebRtcH264Service`) | 無(公開) |
+| `MapOnboardingAssetEndpoints` | `/qr.svg`, `/cert/*` | `ConnectInfoProvider`, `LocalHttpsCertificate` | 無(唯讀公開資產) |
+| `MapDisplayEndpoints` | `/api/displays`, `/api/deck/*`, `/api/display/*` | `DisplayCatalog`, `DeckWindowLauncher`, `DisplayModeController`, `VirtualDisplayController`, `VirtualDisplayInstaller` | 讀=TrustedDevice;改=ProtectedAction;`install`=ActionToken+Local |
+| `MapSideboardEndpoints` | `/api/sideboard/stats\|work-pulse\|refresh` | `GlanceBoardProxy` | 讀=TrustedDevice;`refresh`=ProtectedAction |
+| `MapQuotaEndpoints` | `/api/quotas/*`(12 個) | `AiQuotaService` | 讀=TrustedDevice;改=ProtectedAction |
+| `MapStreamingEndpoints` | `/ws/display`, `/api/stream/webrtc/offer`, `/ws/input` | `DisplayFrameSource`, `WebRtcH264Service`, `WindowsInputController` | TrustedDevice(runtime loop `StreamDisplayAsync`/`ReceiveInputAsync` 同檔) |
+| `MapStreamTransportEndpoints` | `/api/stream/ice`, `/api/stream/turn/*` | ICE / TURN 設定 | 見該 partial |
+| `MapCustomSourceEndpoints` | `/api/custom-sources/*` | `CustomSourceService` / `CustomSourceStore` | 見該 partial |
+| `MapDashboardLayoutEndpoints` | `/api/dashboard/layout*` | `DashboardLayoutService` | 讀=TrustedDevice;寫=ProtectedAction |
+| `MapDiagnosticsEndpoints` | 診斷 / 稽核 | 診斷服務 | 見該 partial |
+| `MapProductUpdateEndpoints` | 產品更新檢查/套用 | 更新服務 | 見該 partial |
+| `MapWindowsNotificationEndpoints` | Windows 通知伴隨程式 | 通知服務 | 見該 partial |
+| (內聯)Auth | `/api/auth/status\|login\|logout` | `HostAccessAuthService` | `login` 設定 session cookie;`status` 公開 |
+| (內聯)Connect | `/api/connect`, `/api/connect/public-endpoint`, `device-code`, `eink-code` | `ConnectInfoProvider`, `PublicEndpointService`, `ConnectionCodeBrokerService` | 變更=ActionToken+Local |
+| (內聯)Session | `/api/session` | `ActionTokenService`, `DeviceTrustService`, `HostAccessAuthService` | 對本機/已信任者發放 action token |
+| (內聯)Devices/Pairing | `/api/devices/status\|revoke\|clear`, `/api/devices/pairing/request\|poll\|pending\|approve\|deny` | `DeviceTrustService` | 握手=PairingTransport;審核=ActionToken+Local;revoke=ProtectedAction |
+| (內聯)Dashboard SSE | `/api/dashboard/events` | `DashboardEventHub` | TrustedDevice |
+| (內聯)Root | `/` | 靜態 `index.html` + AGY OAuth callback | 無 |
+
+---
+
+## 3. 授權 gate(安全層)
+
+授權集中在 5 個 `Require*Async` helper(都在 `Startup.cs`),端點在委派最前面呼叫。層級由鬆到嚴:
+
+```mermaid
+flowchart TD
+ subgraph G1["RequireTrustedDevice(唯讀存取)"]
+ direction TB
+ T1["本機 → 放行"]
+ T2["Host 已登入 → 放行"]
+ T3["裝置已配對(DeviceTrustService.IsTrusted) → 放行"]
+ T4["否則 401(開登入)/ 403(未配對)"]
+ end
+
+ subgraph G2["RequireActionToken(CSRF 防護)"]
+ A1["驗 X-VibeDeck action token header 有效,否則 403"]
+ end
+
+ PROT["RequireProtectedAction = RequireActionToken + RequireTrustedDevice
(所有會改狀態的動作)"]
+ LOCAL["RequireLocalRequest:僅限本機 loopback,否則 403
(PC 專屬:安裝驅動、審核配對、清除裝置、設定公開端點)"]
+ PAIR["RequirePairingTransport:私網 或 信任公開端點,且必須 HTTPS,否則 403
(配對握手 request/poll,來自尚未信任的手機)"]
+
+ G2 --> PROT
+ G1 --> PROT
+```
+
+一句話對應:
+
+- **公開**:`/health`、`/api/stream/capabilities`、`/qr.svg`、`/cert/*`、`/`、`/api/auth/status`。
+- **RequireTrustedDevice**:唯讀資料與即時串流(displays、sideboard、quotas 讀取、dashboard events、`/ws/display`、`/api/stream/webrtc/offer`、`/ws/input`)。
+- **RequireProtectedAction**(=ActionToken+TrustedDevice):改狀態動作(deck 切換、display mode、sideboard refresh、quota 帳號操作)。
+- **ActionToken + RequireLocalRequest**:只能從這台 PC 發起(安裝虛擬顯示器、`/api/devices/clear`、`pairing/pending\|approve\|deny`、`connect/public-endpoint`)。
+- **RequirePairingTransport**:配對握手(`/api/devices/pairing/request\|poll`),限私網或已設定的安全公開 URL + HTTPS。
+
+---
+
+## 4. 為什麼這些仍留在 Startup.cs(本波刻意不動)
+
+- **安全垂直(Auth / Connect / Session / Devices+Pairing)**:這是結構債報告排序 #1(耦合度 5)。它跨 token 格式、cookie、Cloudflare Worker 連線碼、pending/approved 狀態機與持久化。依 `technical-debt-roadmap.md`,**必須先補完整的配對狀態機與端點整合測試,才能拆**,不在低風險機械搬移範圍。這是目前 `Startup.cs` 內聯端點的最大宗。
+- **Dashboard SSE(/api/dashboard/events)**:單一長生命週期事件流;價值低、暫留。
+- **Root `/`**:委派閉包了 `Configure` 的區域變數 `env`(`env.WebRootPath`),移出需另傳參數,非純機械搬移。
+- **共用 helper**:`Require*`、`WriteAudit`、`SocketJsonOptions`、`ParseInt`、`WriteStreamCapabilitiesAsync`、`WriteGlanceBoardResponseAsync`、`WriteQrSvgAsync`/`BuildQrSvg`、`WriteCertificateFileAsync`、`WriteAgyOAuthCallbackAsync` 等被多群共用,留在 `Startup.cs` 當共用層。
+
+---
+
+## 5. 已外移的端點群一覽(純搬移、對外契約不變)
+
+從 `Startup.cs` 外移到各 partial(路徑、授權 gate、服務、DTO、狀態碼、JSON 全部不變):
+
+| partial | 內容 |
+|---|---|
+| `Startup.Display.cs` | 10 個 display/deck 端點 |
+| `Startup.System.cs` | `/health`, `/api/stream/capabilities` |
+| `Startup.OnboardingAssets.cs` | `/qr.svg` + 5 個 `/cert/*` |
+| `Startup.Sideboard.cs` | 3 個 sideboard 端點 |
+| `Startup.Quotas.cs` | 12 個 quota 端點(原本與 pairing 端點交錯,現已收攏) |
+| `Startup.Streaming.cs` | `/ws/display`, `/api/stream/webrtc/offer`, `/ws/input`(與其 runtime loop 同檔) |
+
+`Startup.cs` 於 0.1.36 基準為 1659 行,本波系列重構後降到 **1163 行**;每批 `dotnet build` + 62 個 Host 測試維持全綠。
+
+最終驗收:重構完成後,在本機用 `scripts/package-windows-setup.ps1` 打包安裝並實跑(配對、串流、display、quota),作為對外行為未變的實機關卡。
diff --git a/src/PhoneMonitor.Host/Startup.Display.cs b/src/PhoneMonitor.Host/Startup.Display.cs
new file mode 100644
index 0000000..e93cf88
--- /dev/null
+++ b/src/PhoneMonitor.Host/Startup.Display.cs
@@ -0,0 +1,165 @@
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using PhoneMonitor.Host.Display;
+using PhoneMonitor.Host.Windows;
+
+namespace PhoneMonitor.Host
+{
+ public partial class Startup
+ {
+ // Display catalog, deck-window, display-mode, and virtual-display endpoints.
+ // Extracted verbatim from Startup.cs (VibeDeck 0.1.36) with no behavior change:
+ // identical paths, guards (RequireTrustedDevice / RequireProtectedAction /
+ // RequireActionToken + RequireLocalRequest), resolved services, request DTOs,
+ // status codes, headers, and JSON payloads. Shared helpers (SocketJsonOptions,
+ // NormalizeDeckMode, BuildLocalDeckUrl, Require*Async) remain on the Startup
+ // partial class and are reused as-is.
+ private static void MapDisplayEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/api/displays", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var catalog = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(catalog.GetDisplays()));
+ });
+
+ endpoints.MapPost("/api/deck/launch", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
+ ?? new DeckLaunchRequest();
+ var mode = NormalizeDeckMode(request.Mode);
+ var deckUrl = BuildLocalDeckUrl(mode);
+ var launcher = context.RequestServices.GetRequiredService();
+ var result = launcher.Launch(deckUrl, mode);
+
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapPost("/api/deck/return", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var launcher = context.RequestServices.GetRequiredService();
+ var result = launcher.ReturnToPrimary();
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : StatusCodes.Status404NotFound;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapGet("/api/display/modes", async context =>
+ {
+ var controller = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(controller.GetPresets()));
+ });
+
+ endpoints.MapPost("/api/display/mode", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var controller = context.RequestServices.GetRequiredService();
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body)
+ ?? new SetDisplayModeRequest();
+ var result = controller.Apply(request.Width, request.Height, request.RefreshRate);
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapGet("/api/display/status", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var controller = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(controller.GetStatus()));
+ });
+
+ endpoints.MapGet("/api/display/install/status", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var installer = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ context.Response.Headers["Cache-Control"] = "no-store";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(installer.GetStatus()));
+ });
+
+ endpoints.MapPost("/api/display/install", async context =>
+ {
+ if (!await RequireActionTokenAsync(context) || !await RequireLocalRequestAsync(context))
+ {
+ return;
+ }
+
+ var installer = context.RequestServices.GetRequiredService();
+ var status = installer.StartInstall();
+ context.Response.StatusCode = status.State == "failed" || status.State == "unavailable"
+ ? StatusCodes.Status400BadRequest
+ : StatusCodes.Status202Accepted;
+ context.Response.ContentType = "application/json";
+ context.Response.Headers["Cache-Control"] = "no-store";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(status));
+ });
+
+ endpoints.MapPost("/api/display/enable", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var controller = context.RequestServices.GetRequiredService();
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body)
+ ?? new EnableDisplayRequest();
+ var status = controller.Enable(request.Width, request.Height, request.RefreshRate);
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(status));
+ });
+
+ endpoints.MapPost("/api/display/disable", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var controller = context.RequestServices.GetRequiredService();
+ var status = controller.Disable();
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(status));
+ });
+ }
+ }
+}
diff --git a/src/PhoneMonitor.Host/Startup.OnboardingAssets.cs b/src/PhoneMonitor.Host/Startup.OnboardingAssets.cs
new file mode 100644
index 0000000..ef3ae3a
--- /dev/null
+++ b/src/PhoneMonitor.Host/Startup.OnboardingAssets.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using PhoneMonitor.Host.Connect;
+using PhoneMonitor.Host.Security;
+
+namespace PhoneMonitor.Host
+{
+ public partial class Startup
+ {
+ // Read-only onboarding assets phones fetch during pairing/HTTPS setup:
+ // the pairing QR image and the downloadable local-CA / host certificates
+ // (canonical vibedeck-* URLs plus legacy phone-monitor-* aliases for
+ // already-bookmarked phones). Extracted verbatim from Startup.cs; the
+ // shared writers WriteQrSvgAsync/BuildQrSvg/WriteCertificateFileAsync remain
+ // on the Startup partial class.
+ private static void MapOnboardingAssetEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/qr.svg", async context =>
+ {
+ var provider = context.RequestServices.GetRequiredService();
+ var connectInfo = provider.Get(context);
+ var phonePageUrl = new Uri(new Uri(connectInfo.PreferredUrl), "index.html").ToString();
+ await WriteQrSvgAsync(context, phonePageUrl);
+ });
+
+ // Canonical product cert URLs; legacy phone-monitor-* paths stay for already-bookmarked phones.
+ endpoints.MapGet("/cert/vibedeck-root.cer", async context =>
+ {
+ await WriteCertificateFileAsync(
+ context,
+ LocalHttpsCertificate.RootCertificatePath,
+ "vibedeck-root.cer",
+ "application/x-x509-ca-cert");
+ });
+
+ endpoints.MapGet("/cert/vibedeck-root.crt", async context =>
+ {
+ await WriteCertificateFileAsync(
+ context,
+ LocalHttpsCertificate.RootCertificatePath,
+ "vibedeck-root.crt",
+ "application/x-x509-ca-cert");
+ });
+
+ endpoints.MapGet("/cert/vibedeck-host.cer", async context =>
+ {
+ await WriteCertificateFileAsync(
+ context,
+ LocalHttpsCertificate.HostCertificatePath,
+ "vibedeck-host.cer",
+ "application/x-x509-ca-cert");
+ });
+
+ endpoints.MapGet("/cert/phone-monitor-root.cer", async context =>
+ {
+ await WriteCertificateFileAsync(
+ context,
+ LocalHttpsCertificate.RootCertificatePath,
+ LocalHttpsCertificate.RootCerFileName,
+ "application/x-x509-ca-cert");
+ });
+
+ endpoints.MapGet("/cert/phone-monitor-host.cer", async context =>
+ {
+ await WriteCertificateFileAsync(
+ context,
+ LocalHttpsCertificate.HostCertificatePath,
+ LocalHttpsCertificate.HostCerFileName,
+ "application/x-x509-ca-cert");
+ });
+ }
+ }
+}
diff --git a/src/PhoneMonitor.Host/Startup.Quotas.cs b/src/PhoneMonitor.Host/Startup.Quotas.cs
new file mode 100644
index 0000000..a23341d
--- /dev/null
+++ b/src/PhoneMonitor.Host/Startup.Quotas.cs
@@ -0,0 +1,259 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using PhoneMonitor.Host.Quotas;
+
+namespace PhoneMonitor.Host
+{
+ public partial class Startup
+ {
+ // AI quota snapshot + Codex/AGY account management endpoints, all backed by
+ // AiQuotaService. Extracted verbatim from Startup.cs (they were interleaved
+ // with pairing endpoints in the original file); shared helpers
+ // (SocketJsonOptions, WriteAudit, IsFalseValue, BuildAgyOAuthRedirectUri,
+ // WriteAgyOAuthCallbackAsync, Require*Async) remain on the Startup partial
+ // class. Request DTOs CodexAccountRequest/AgyAccountRequest live in the same
+ // namespace.
+ private static void MapQuotaEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/api/quotas", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(await quotas.GetSnapshotAsync(context.RequestAborted)));
+ });
+
+ endpoints.MapPost("/api/quotas/refresh", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(await quotas.RefreshSnapshotAsync(context.RequestAborted)));
+ });
+
+ endpoints.MapGet("/api/quotas/codex/profiles", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ context.Response.Headers["Cache-Control"] = "no-store";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(quotas.ListCodexProfiles()));
+ });
+
+ endpoints.MapPost("/api/quotas/codex/switch", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
+ ?? new CodexAccountRequest();
+ if (string.IsNullOrWhiteSpace(request.AccountId) && string.IsNullOrWhiteSpace(request.Email))
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(new
+ {
+ success = false,
+ code = "quota.codex_profile_required",
+ message = "Select a Codex profile first."
+ }));
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ var result = quotas.SwitchCodexAccount(request.AccountId, request.Email);
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : string.Equals(result.Code, "quota.codex_profile_not_found", StringComparison.Ordinal)
+ ? StatusCodes.Status404NotFound
+ : StatusCodes.Status500InternalServerError;
+ WriteAudit(
+ context,
+ result.Success ? "information" : "error",
+ "quota",
+ "codex_switch",
+ result.Success ? "success" : "failed",
+ request.Email ?? request.AccountId,
+ new Dictionary
+ {
+ ["code"] = result.Code ?? string.Empty
+ });
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapPost("/api/quotas/codex/reauth", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ var result = quotas.ReAuthCodex();
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : StatusCodes.Status500InternalServerError;
+ WriteAudit(
+ context,
+ result.Success ? "information" : "error",
+ "quota",
+ "codex_reauth",
+ result.Success ? "success" : "failed",
+ null,
+ new Dictionary
+ {
+ ["code"] = result.Code ?? string.Empty
+ });
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapPost("/api/quotas/codex/profile/delete", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
+ ?? new CodexAccountRequest();
+ var quotas = context.RequestServices.GetRequiredService();
+ var result = quotas.DeleteCodexProfile(request.AccountId, request.Email);
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : StatusCodes.Status404NotFound;
+ WriteAudit(
+ context,
+ result.Success ? "information" : "warning",
+ "quota",
+ "codex_profile_delete",
+ result.Success ? "success" : "not_found",
+ request.Email ?? request.AccountId,
+ new Dictionary
+ {
+ ["code"] = result.Code ?? string.Empty
+ });
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapPost("/api/quotas/agy/import", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(quotas.ImportAgyAccountsFromAntigravity()));
+ });
+
+ endpoints.MapPost("/api/quotas/agy/oauth/start", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ var openQuery = context.Request.Query["open"].ToString();
+ var openBrowser = !IsFalseValue(openQuery);
+ var redirectUri = BuildAgyOAuthRedirectUri(context.Request);
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(quotas.StartAgyOAuth(redirectUri, openBrowser)));
+ });
+
+ endpoints.MapPost("/api/quotas/agy/cli/open", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ var openQuery = context.Request.Query["open"].ToString();
+ var openWindow = !IsFalseValue(openQuery);
+ var result = quotas.OpenAgyCli(openWindow);
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : StatusCodes.Status404NotFound;
+ WriteAudit(
+ context,
+ result.Success ? "information" : "error",
+ "quota",
+ "agy_cli_open",
+ result.Success ? "opened" : "failed",
+ null,
+ new Dictionary
+ {
+ ["account_scope"] = "native-session"
+ });
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapPost("/api/quotas/agy/account/delete", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
+ ?? new AgyAccountRequest();
+ var result = quotas.DeleteAgyAccount(request.AccountId, request.Email);
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : StatusCodes.Status404NotFound;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+
+ endpoints.MapGet("/api/quotas/agy/oauth/callback", async context =>
+ {
+ await WriteAgyOAuthCallbackAsync(context);
+ });
+
+ endpoints.MapPost("/api/quotas/codex/account/delete", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var quotas = context.RequestServices.GetRequiredService();
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
+ ?? new AgyAccountRequest();
+ var result = quotas.DeleteCodexAccount(request.AccountId, request.Email);
+ context.Response.StatusCode = result.Success
+ ? StatusCodes.Status200OK
+ : StatusCodes.Status404NotFound;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(result));
+ });
+ }
+ }
+}
diff --git a/src/PhoneMonitor.Host/Startup.Sideboard.cs b/src/PhoneMonitor.Host/Startup.Sideboard.cs
new file mode 100644
index 0000000..6798382
--- /dev/null
+++ b/src/PhoneMonitor.Host/Startup.Sideboard.cs
@@ -0,0 +1,52 @@
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using PhoneMonitor.Host.Sideboard;
+
+namespace PhoneMonitor.Host
+{
+ public partial class Startup
+ {
+ // GlanceBoard sideboard endpoints (stats / work-pulse / manual refresh).
+ // Extracted verbatim from Startup.cs with no behavior change; the shared
+ // response writer WriteGlanceBoardResponseAsync remains on the Startup
+ // partial class and is reused as-is.
+ private static void MapSideboardEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/api/sideboard/stats", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var proxy = context.RequestServices.GetRequiredService();
+ await WriteGlanceBoardResponseAsync(context, await proxy.GetStatsAsync(context.RequestAborted));
+ });
+
+ endpoints.MapGet("/api/sideboard/work-pulse", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var proxy = context.RequestServices.GetRequiredService();
+ await WriteGlanceBoardResponseAsync(context, await proxy.GetWorkPulseAsync(context.RequestAborted));
+ });
+
+ endpoints.MapPost("/api/sideboard/refresh", async context =>
+ {
+ if (!await RequireProtectedActionAsync(context))
+ {
+ return;
+ }
+
+ var proxy = context.RequestServices.GetRequiredService();
+ await WriteGlanceBoardResponseAsync(context, await proxy.RefreshStatsAsync(context.RequestAborted));
+ });
+ }
+ }
+}
diff --git a/src/PhoneMonitor.Host/Startup.Streaming.cs b/src/PhoneMonitor.Host/Startup.Streaming.cs
index d32b59c..5fd6a3b 100644
--- a/src/PhoneMonitor.Host/Startup.Streaming.cs
+++ b/src/PhoneMonitor.Host/Startup.Streaming.cs
@@ -4,6 +4,10 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
using PhoneMonitor.Host.Streaming;
using PhoneMonitor.Host.Windows;
@@ -11,6 +15,100 @@ namespace PhoneMonitor.Host
{
public partial class Startup
{
+ // Live display/input streaming surface: JPEG-over-WebSocket display,
+ // WebRTC H.264 signalling, and the input channel. Extracted verbatim from
+ // Startup.cs (no behavior change); the runtime loops StreamDisplayAsync /
+ // ReceiveInputAsync live in this same file, and ParseInt / SocketJsonOptions
+ // remain on the Startup partial class.
+ private static void MapStreamingEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ endpoints.Map("/ws/display", async context =>
+ {
+ if (!context.WebSockets.IsWebSocketRequest)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var deviceName = context.Request.Query["deviceName"].ToString();
+ var fps = ParseInt(context.Request.Query["fps"], 10, 1, 60);
+ var quality = ParseInt(context.Request.Query["quality"], 55, 25, 85);
+ var frameSource = context.RequestServices.GetRequiredService();
+ using var socket = await context.WebSockets.AcceptWebSocketAsync();
+ await StreamDisplayAsync(socket, frameSource, deviceName, fps, quality, context.RequestAborted);
+ });
+
+ endpoints.MapPost("/api/stream/webrtc/offer", async context =>
+ {
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
+ ?? new WebRtcOfferRequest();
+ var webrtc = context.RequestServices.GetRequiredService();
+ if (!webrtc.IsAvailable)
+ {
+ context.Response.StatusCode = StatusCodes.Status501NotImplemented;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(new
+ {
+ error = "WebRTC H.264 requires ffmpeg.exe on the Host."
+ }));
+ return;
+ }
+
+ try
+ {
+ var answer = await webrtc.CreateAnswerAsync(
+ request.Sdp,
+ request.DeviceName ?? string.Empty,
+ Math.Max(1, Math.Min(60, request.Fps)),
+ Math.Max(25, Math.Min(85, request.Quality)),
+ context.RequestAborted);
+ context.Response.ContentType = "application/json";
+ context.Response.Headers["Cache-Control"] = "no-store";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(answer));
+ }
+ catch (ArgumentException error)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(new { error = error.Message }));
+ }
+ catch (Exception error)
+ {
+ context.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ context.Response.ContentType = "application/json";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(new { error = error.Message }));
+ }
+ });
+
+ endpoints.Map("/ws/input", async context =>
+ {
+ if (!context.WebSockets.IsWebSocketRequest)
+ {
+ context.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ if (!await RequireTrustedDeviceAsync(context))
+ {
+ return;
+ }
+
+ using var socket = await context.WebSockets.AcceptWebSocketAsync();
+ var input = context.RequestServices.GetRequiredService();
+ await ReceiveInputAsync(socket, input, context.RequestAborted);
+ });
+ }
+
private static async Task StreamDisplayAsync(
WebSocket socket,
DisplayFrameSource frameSource,
diff --git a/src/PhoneMonitor.Host/Startup.System.cs b/src/PhoneMonitor.Host/Startup.System.cs
new file mode 100644
index 0000000..39c7bc1
--- /dev/null
+++ b/src/PhoneMonitor.Host/Startup.System.cs
@@ -0,0 +1,38 @@
+using System.Text.Json;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace PhoneMonitor.Host
+{
+ public partial class Startup
+ {
+ // Unauthenticated system/status endpoints: liveness and stream capability
+ // discovery. Extracted verbatim from Startup.cs; the capability payload
+ // builder WriteStreamCapabilitiesAsync remains on the Startup partial class.
+ private static void MapSystemEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ endpoints.MapGet("/health", async context =>
+ {
+ context.Response.ContentType = "application/json";
+ context.Response.Headers["Cache-Control"] = "no-store";
+ await context.Response.WriteAsync(JsonSerializer.Serialize(new
+ {
+ status = "ok",
+ app = "VibeDeck.Host",
+ product = AppPaths.ProductName,
+ version = ProductVersion.Current,
+ installed = AppPaths.IsInstalledLayout,
+ transport = "webrtc-h264+jpeg-fallback"
+ }));
+ });
+
+ endpoints.MapGet("/api/stream/capabilities", async context =>
+ {
+ context.Response.ContentType = "application/json";
+ await WriteStreamCapabilitiesAsync(context);
+ });
+ }
+ }
+}
diff --git a/src/PhoneMonitor.Host/Startup.cs b/src/PhoneMonitor.Host/Startup.cs
index c8872b3..2dc408d 100644
--- a/src/PhoneMonitor.Host/Startup.cs
+++ b/src/PhoneMonitor.Host/Startup.cs
@@ -258,21 +258,12 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
MapStreamTransportEndpoints(endpoints);
MapProductUpdateEndpoints(endpoints);
MapWindowsNotificationEndpoints(endpoints);
-
- endpoints.MapGet("/health", async context =>
- {
- context.Response.ContentType = "application/json";
- context.Response.Headers["Cache-Control"] = "no-store";
- await context.Response.WriteAsync(JsonSerializer.Serialize(new
- {
- status = "ok",
- app = "VibeDeck.Host",
- product = AppPaths.ProductName,
- version = ProductVersion.Current,
- installed = AppPaths.IsInstalledLayout,
- transport = "webrtc-h264+jpeg-fallback"
- }));
- });
+ MapDisplayEndpoints(endpoints);
+ MapSystemEndpoints(endpoints);
+ MapOnboardingAssetEndpoints(endpoints);
+ MapSideboardEndpoints(endpoints);
+ MapQuotaEndpoints(endpoints);
+ MapStreamingEndpoints(endpoints);
endpoints.MapGet("/api/auth/status", async context =>
{
@@ -473,12 +464,6 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new
}
});
- endpoints.MapGet("/api/stream/capabilities", async context =>
- {
- context.Response.ContentType = "application/json";
- await WriteStreamCapabilitiesAsync(context);
- });
-
endpoints.MapGet("/api/session", async context =>
{
if (!IsLocalRequest(context) &&
@@ -576,471 +561,6 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(devices.GetStatus(
await context.Response.WriteAsync(JsonSerializer.Serialize(result));
});
- endpoints.MapGet("/qr.svg", async context =>
- {
- var provider = context.RequestServices.GetRequiredService();
- var connectInfo = provider.Get(context);
- var phonePageUrl = new Uri(new Uri(connectInfo.PreferredUrl), "index.html").ToString();
- await WriteQrSvgAsync(context, phonePageUrl);
- });
-
- // Canonical product cert URLs; legacy phone-monitor-* paths stay for already-bookmarked phones.
- endpoints.MapGet("/cert/vibedeck-root.cer", async context =>
- {
- await WriteCertificateFileAsync(
- context,
- LocalHttpsCertificate.RootCertificatePath,
- "vibedeck-root.cer",
- "application/x-x509-ca-cert");
- });
-
- endpoints.MapGet("/cert/vibedeck-root.crt", async context =>
- {
- await WriteCertificateFileAsync(
- context,
- LocalHttpsCertificate.RootCertificatePath,
- "vibedeck-root.crt",
- "application/x-x509-ca-cert");
- });
-
- endpoints.MapGet("/cert/vibedeck-host.cer", async context =>
- {
- await WriteCertificateFileAsync(
- context,
- LocalHttpsCertificate.HostCertificatePath,
- "vibedeck-host.cer",
- "application/x-x509-ca-cert");
- });
-
- endpoints.MapGet("/cert/phone-monitor-root.cer", async context =>
- {
- await WriteCertificateFileAsync(
- context,
- LocalHttpsCertificate.RootCertificatePath,
- LocalHttpsCertificate.RootCerFileName,
- "application/x-x509-ca-cert");
- });
-
- endpoints.MapGet("/cert/phone-monitor-host.cer", async context =>
- {
- await WriteCertificateFileAsync(
- context,
- LocalHttpsCertificate.HostCertificatePath,
- LocalHttpsCertificate.HostCerFileName,
- "application/x-x509-ca-cert");
- });
-
- endpoints.MapGet("/api/displays", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var catalog = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(catalog.GetDisplays()));
- });
-
- endpoints.MapPost("/api/deck/launch", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
- ?? new DeckLaunchRequest();
- var mode = NormalizeDeckMode(request.Mode);
- var deckUrl = BuildLocalDeckUrl(mode);
- var launcher = context.RequestServices.GetRequiredService();
- var result = launcher.Launch(deckUrl, mode);
-
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : StatusCodes.Status400BadRequest;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapPost("/api/deck/return", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var launcher = context.RequestServices.GetRequiredService();
- var result = launcher.ReturnToPrimary();
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : StatusCodes.Status404NotFound;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapGet("/api/display/modes", async context =>
- {
- var controller = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(controller.GetPresets()));
- });
-
- endpoints.MapPost("/api/display/mode", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var controller = context.RequestServices.GetRequiredService();
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body)
- ?? new SetDisplayModeRequest();
- var result = controller.Apply(request.Width, request.Height, request.RefreshRate);
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapGet("/api/display/status", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var controller = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(controller.GetStatus()));
- });
-
- endpoints.MapGet("/api/display/install/status", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var installer = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- context.Response.Headers["Cache-Control"] = "no-store";
- await context.Response.WriteAsync(JsonSerializer.Serialize(installer.GetStatus()));
- });
-
- endpoints.MapPost("/api/display/install", async context =>
- {
- if (!await RequireActionTokenAsync(context) || !await RequireLocalRequestAsync(context))
- {
- return;
- }
-
- var installer = context.RequestServices.GetRequiredService();
- var status = installer.StartInstall();
- context.Response.StatusCode = status.State == "failed" || status.State == "unavailable"
- ? StatusCodes.Status400BadRequest
- : StatusCodes.Status202Accepted;
- context.Response.ContentType = "application/json";
- context.Response.Headers["Cache-Control"] = "no-store";
- await context.Response.WriteAsync(JsonSerializer.Serialize(status));
- });
-
- endpoints.MapGet("/api/sideboard/stats", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var proxy = context.RequestServices.GetRequiredService();
- await WriteGlanceBoardResponseAsync(context, await proxy.GetStatsAsync(context.RequestAborted));
- });
-
- endpoints.MapGet("/api/sideboard/work-pulse", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var proxy = context.RequestServices.GetRequiredService();
- await WriteGlanceBoardResponseAsync(context, await proxy.GetWorkPulseAsync(context.RequestAborted));
- });
-
- endpoints.MapPost("/api/sideboard/refresh", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var proxy = context.RequestServices.GetRequiredService();
- await WriteGlanceBoardResponseAsync(context, await proxy.RefreshStatsAsync(context.RequestAborted));
- });
-
- endpoints.MapGet("/api/quotas", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(await quotas.GetSnapshotAsync(context.RequestAborted)));
- });
-
- endpoints.MapPost("/api/quotas/refresh", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(await quotas.RefreshSnapshotAsync(context.RequestAborted)));
- });
-
- endpoints.MapGet("/api/quotas/codex/profiles", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- context.Response.Headers["Cache-Control"] = "no-store";
- await context.Response.WriteAsync(JsonSerializer.Serialize(quotas.ListCodexProfiles()));
- });
-
- endpoints.MapPost("/api/quotas/codex/switch", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
- ?? new CodexAccountRequest();
- if (string.IsNullOrWhiteSpace(request.AccountId) && string.IsNullOrWhiteSpace(request.Email))
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(new
- {
- success = false,
- code = "quota.codex_profile_required",
- message = "Select a Codex profile first."
- }));
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- var result = quotas.SwitchCodexAccount(request.AccountId, request.Email);
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : string.Equals(result.Code, "quota.codex_profile_not_found", StringComparison.Ordinal)
- ? StatusCodes.Status404NotFound
- : StatusCodes.Status500InternalServerError;
- WriteAudit(
- context,
- result.Success ? "information" : "error",
- "quota",
- "codex_switch",
- result.Success ? "success" : "failed",
- request.Email ?? request.AccountId,
- new Dictionary
- {
- ["code"] = result.Code ?? string.Empty
- });
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapPost("/api/quotas/codex/reauth", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- var result = quotas.ReAuthCodex();
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : StatusCodes.Status500InternalServerError;
- WriteAudit(
- context,
- result.Success ? "information" : "error",
- "quota",
- "codex_reauth",
- result.Success ? "success" : "failed",
- null,
- new Dictionary
- {
- ["code"] = result.Code ?? string.Empty
- });
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapPost("/api/quotas/codex/profile/delete", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
- ?? new CodexAccountRequest();
- var quotas = context.RequestServices.GetRequiredService();
- var result = quotas.DeleteCodexProfile(request.AccountId, request.Email);
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : StatusCodes.Status404NotFound;
- WriteAudit(
- context,
- result.Success ? "information" : "warning",
- "quota",
- "codex_profile_delete",
- result.Success ? "success" : "not_found",
- request.Email ?? request.AccountId,
- new Dictionary
- {
- ["code"] = result.Code ?? string.Empty
- });
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapPost("/api/quotas/agy/import", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(quotas.ImportAgyAccountsFromAntigravity()));
- });
-
- endpoints.MapPost("/api/quotas/agy/oauth/start", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- var openQuery = context.Request.Query["open"].ToString();
- var openBrowser = !IsFalseValue(openQuery);
- var redirectUri = BuildAgyOAuthRedirectUri(context.Request);
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(quotas.StartAgyOAuth(redirectUri, openBrowser)));
- });
-
- endpoints.MapPost("/api/quotas/agy/cli/open", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- var openQuery = context.Request.Query["open"].ToString();
- var openWindow = !IsFalseValue(openQuery);
- var result = quotas.OpenAgyCli(openWindow);
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : StatusCodes.Status404NotFound;
- WriteAudit(
- context,
- result.Success ? "information" : "error",
- "quota",
- "agy_cli_open",
- result.Success ? "opened" : "failed",
- null,
- new Dictionary
- {
- ["account_scope"] = "native-session"
- });
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapPost("/api/quotas/agy/account/delete", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
- ?? new AgyAccountRequest();
- var result = quotas.DeleteAgyAccount(request.AccountId, request.Email);
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : StatusCodes.Status404NotFound;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
- endpoints.MapGet("/api/quotas/agy/oauth/callback", async context =>
- {
- await WriteAgyOAuthCallbackAsync(context);
- });
-
- endpoints.MapPost("/api/display/enable", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var controller = context.RequestServices.GetRequiredService();
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body)
- ?? new EnableDisplayRequest();
- var status = controller.Enable(request.Width, request.Height, request.RefreshRate);
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(status));
- });
-
- endpoints.MapPost("/api/display/disable", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var controller = context.RequestServices.GetRequiredService();
- var status = controller.Disable();
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(status));
- });
-
- endpoints.Map("/ws/display", async context =>
- {
- if (!context.WebSockets.IsWebSocketRequest)
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- return;
- }
-
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var deviceName = context.Request.Query["deviceName"].ToString();
- var fps = ParseInt(context.Request.Query["fps"], 10, 1, 60);
- var quality = ParseInt(context.Request.Query["quality"], 55, 25, 85);
- var frameSource = context.RequestServices.GetRequiredService();
- using var socket = await context.WebSockets.AcceptWebSocketAsync();
- await StreamDisplayAsync(socket, frameSource, deviceName, fps, quality, context.RequestAborted);
- });
-
endpoints.MapPost("/api/devices/pairing/request", async context =>
{
if (!await RequirePairingTransportAsync(context)) return;
@@ -1071,24 +591,6 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new
await context.Response.WriteAsync(JsonSerializer.Serialize(result));
});
- endpoints.MapPost("/api/quotas/codex/account/delete", async context =>
- {
- if (!await RequireProtectedActionAsync(context))
- {
- return;
- }
-
- var quotas = context.RequestServices.GetRequiredService();
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
- ?? new AgyAccountRequest();
- var result = quotas.DeleteCodexAccount(request.AccountId, request.Email);
- context.Response.StatusCode = result.Success
- ? StatusCodes.Status200OK
- : StatusCodes.Status404NotFound;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(result));
- });
-
endpoints.MapPost("/api/devices/pairing/poll", async context =>
{
if (!await RequirePairingTransportAsync(context)) return;
@@ -1209,71 +711,6 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(new
}
});
- endpoints.MapPost("/api/stream/webrtc/offer", async context =>
- {
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- var request = await JsonSerializer.DeserializeAsync(context.Request.Body, SocketJsonOptions)
- ?? new WebRtcOfferRequest();
- var webrtc = context.RequestServices.GetRequiredService();
- if (!webrtc.IsAvailable)
- {
- context.Response.StatusCode = StatusCodes.Status501NotImplemented;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(new
- {
- error = "WebRTC H.264 requires ffmpeg.exe on the Host."
- }));
- return;
- }
-
- try
- {
- var answer = await webrtc.CreateAnswerAsync(
- request.Sdp,
- request.DeviceName ?? string.Empty,
- Math.Max(1, Math.Min(60, request.Fps)),
- Math.Max(25, Math.Min(85, request.Quality)),
- context.RequestAborted);
- context.Response.ContentType = "application/json";
- context.Response.Headers["Cache-Control"] = "no-store";
- await context.Response.WriteAsync(JsonSerializer.Serialize(answer));
- }
- catch (ArgumentException error)
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(new { error = error.Message }));
- }
- catch (Exception error)
- {
- context.Response.StatusCode = StatusCodes.Status500InternalServerError;
- context.Response.ContentType = "application/json";
- await context.Response.WriteAsync(JsonSerializer.Serialize(new { error = error.Message }));
- }
- });
-
- endpoints.Map("/ws/input", async context =>
- {
- if (!context.WebSockets.IsWebSocketRequest)
- {
- context.Response.StatusCode = StatusCodes.Status400BadRequest;
- return;
- }
-
- if (!await RequireTrustedDeviceAsync(context))
- {
- return;
- }
-
- using var socket = await context.WebSockets.AcceptWebSocketAsync();
- var input = context.RequestServices.GetRequiredService();
- await ReceiveInputAsync(socket, input, context.RequestAborted);
- });
-
endpoints.MapGet("/", async context =>
{
if (IsAgyOAuthCallbackRequest(context.Request))
diff --git a/src/PhoneMonitor.Host/Windows/DisplayFrameSource.cs b/src/PhoneMonitor.Host/Windows/DisplayFrameSource.cs
index 2bd11ae..2dd6df7 100644
--- a/src/PhoneMonitor.Host/Windows/DisplayFrameSource.cs
+++ b/src/PhoneMonitor.Host/Windows/DisplayFrameSource.cs
@@ -33,6 +33,7 @@ public sealed class DisplayFrameSource : IDisposable
// DXGI Capturer for hardware-accelerated screen capture.
private DxgiFrameCapturer _dxgiCapturer;
private string _dxgiDeviceName;
+ private bool _hasEverCaptured;
public DisplayFrameSource(DisplayCatalog catalog)
{
@@ -74,6 +75,7 @@ public DisplayBitmapFrame CaptureBitmapFrame(string deviceName, ReusableBitmapHo
{
holder.Value?.Dispose();
holder.Value = new Bitmap(width, height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
+ _hasEverCaptured = false; // New bitmap is all-black; force GDI on next frame.
}
var bitmap = holder.Value;
@@ -105,7 +107,10 @@ public DisplayBitmapFrame CaptureBitmapFrame(string deviceName, ReusableBitmapHo
}
// Fallback to GDI capture if DXGI is unsupported or fails (e.g. UAC prompts, device resets)
- if (!dxgiSuccess)
+ // Also falls through when DXGI succeeded but had no new frame AND the bitmap
+ // has never been populated (all-black after allocation). This prevents sending
+ // black frames on initial connection or after a DXGI reset.
+ if (!dxgiSuccess || (dxgiSuccess && !hasNewFrame && !_hasEverCaptured))
{
using (var graphics = Graphics.FromImage(bitmap))
{
@@ -113,6 +118,8 @@ public DisplayBitmapFrame CaptureBitmapFrame(string deviceName, ReusableBitmapHo
}
}
+ _hasEverCaptured = true;
+
// Render the cursor on top of the captured frame
bool hasCursor = false;
int cursorX = 0, cursorY = 0;
diff --git a/src/PhoneMonitor.Host/wwwroot/css/00-base-tokens.css b/src/PhoneMonitor.Host/wwwroot/css/00-base-tokens.css
new file mode 100644
index 0000000..b76cb1d
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/css/00-base-tokens.css
@@ -0,0 +1,97 @@
+ :root {
+ color-scheme: dark;
+ font-family: "Outfit", "Segoe UI", system-ui, sans-serif;
+ background: #101820;
+ color: #f5f7fb;
+ --bg: #101820;
+ --surface: #17212c;
+ --surface-2: #1d2a36;
+ --surface-3: #223040;
+ --line: rgba(143, 209, 255, .16);
+ --line-strong: rgba(143, 209, 255, .28);
+ --text: #f5f7fb;
+ --muted: #b9c4d0;
+ --quiet: #7f91a5;
+ --green: #70d98b;
+ --yellow: #ffd27a;
+ --blue: #8fd1ff;
+ --viewer-width: 100dvw;
+ --viewer-height: 100dvh;
+ /* premium gradient tokens */
+ --grad-accent: linear-gradient(90deg, #8fd1ff 0%, #70d98b 100%);
+ --grad-warn: linear-gradient(90deg, #ffd27a 0%, #ff9d5c 100%);
+ --grad-hot: linear-gradient(90deg, #ff6b6b 0%, #ffd27a 100%);
+}
+
+.host-auth-gate {
+ position: fixed;
+ z-index: 1000;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ padding: 20px;
+ background: rgba(9, 14, 21, .92);
+ backdrop-filter: blur(14px);
+}
+
+.host-auth-gate[hidden] {
+ display: none;
+}
+
+.host-auth-card {
+ width: min(420px, 100%);
+ display: grid;
+ gap: 12px;
+ box-sizing: border-box;
+ padding: 28px;
+ border: 1px solid rgba(143, 209, 255, .24);
+ border-radius: 16px;
+ background: #111820;
+ color: #f5f7fb;
+ box-shadow: 0 24px 80px rgba(0, 0, 0, .42);
+}
+
+.host-auth-card h1,
+.host-auth-card p {
+ margin: 0;
+}
+
+.host-auth-card p,
+.host-auth-card small {
+ color: #aebdca;
+ line-height: 1.5;
+}
+
+.host-auth-kicker {
+ color: #8fd1ff;
+ font: 700 12px/1.2 "Share Tech Mono", monospace;
+ letter-spacing: .12em;
+}
+
+.host-auth-card label {
+ margin-top: 8px;
+ color: #d9e5ef;
+ font-size: 13px;
+}
+
+.host-auth-card input {
+ width: 100%;
+ box-sizing: border-box;
+ padding: 12px 14px;
+ border: 1px solid rgba(143, 209, 255, .28);
+ border-radius: 8px;
+ background: #0b1118;
+ color: #fff;
+ font: inherit;
+}
+
+.host-auth-card button {
+ border: 0;
+ cursor: pointer;
+}
+
+.host-auth-error {
+ min-height: 1.5em;
+ color: #ff9e9e !important;
+}
+
diff --git a/src/PhoneMonitor.Host/wwwroot/css/10-core.css b/src/PhoneMonitor.Host/wwwroot/css/10-core.css
new file mode 100644
index 0000000..64d391d
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/css/10-core.css
@@ -0,0 +1,4673 @@
+/* BOOX Go Color / color E Ink mode. Enabled by the Android shell or ?eink=1. */
+body.eink-client {
+ --viewer-width: 100dvw;
+ --viewer-height: 100dvh;
+ color: #171714;
+ background: #f2f0e8;
+ font-family: Georgia, "Noto Serif TC", serif;
+ -webkit-font-smoothing: auto;
+}
+
+body.eink-client #displayMode,
+body.eink-client .display-view,
+body.eink-client #keepAwake,
+body.eink-client .setup-panels,
+body.eink-client header .panel,
+body.eink-client .connect-panel,
+body.eink-client .new-device-panel,
+body.eink-client .device-management-panel,
+body.eink-client .diagnostics-panel,
+body.eink-client .pair-success-banner,
+body.eink-client .driver-state {
+ display: none !important;
+}
+
+/* Keep pairing usable on e-ink until the phone is trusted. */
+body.eink-client.device-trusted .phone-pair-rescue {
+ display: none !important;
+}
+
+body.eink-client #einkModeToggle.active {
+ background: #24241f;
+ color: #f2f0e8;
+ border-color: #24241f;
+}
+
+body.eink-client header {
+ min-height: 68px;
+ padding: 10px 18px;
+ border-bottom: 3px solid #24241f;
+ box-shadow: none;
+}
+
+body.eink-client main {
+ min-height: 0;
+ height: auto;
+ max-height: none;
+ padding: 0 0 28px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+body.eink-client header,
+body.eink-client main {
+ background: #f2f0e8;
+}
+
+body.eink-client .mode-button,
+body.eink-client .quota-tab,
+body.eink-client .skin-picker button,
+body.eink-client button {
+ min-height: 44px;
+ border: 2px solid #24241f;
+ border-radius: 2px;
+ color: #171714;
+ background: #f2f0e8;
+ box-shadow: none;
+ transition: none;
+}
+
+body.eink-client .mode-button.active,
+body.eink-client .quota-tab.active,
+body.eink-client .skin-picker button.active {
+ color: #fff;
+ background: #24241f;
+ border-color: #24241f;
+}
+
+body.eink-client .sideboard-view,
+body.eink-client .quota-view {
+ width: 100%;
+ max-width: none;
+ align-self: stretch;
+}
+
+body.eink-client .sideboard-shell,
+body.eink-client .quota-shell {
+ min-height: 0;
+ height: auto;
+ padding: 18px;
+ color: #171714;
+ background: #f2f0e8;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ overflow: visible;
+}
+
+body.eink-client .sideboard-shell::before,
+body.eink-client .sideboard-shell::after {
+ display: none;
+}
+
+/* Always paper stack on e-ink — never the desktop 3-column board. */
+body.eink-client .sideboard-shell {
+ grid-template-columns: 1fr !important;
+ grid-template-rows: auto !important;
+ grid-auto-rows: max-content !important;
+ align-content: start;
+ gap: 14px;
+}
+
+body.eink-client .sideboard-top {
+ padding-bottom: 10px;
+ border-bottom: 3px solid #24241f;
+}
+
+body.eink-client .side-panel,
+body.eink-client .metric-card,
+body.eink-client .quota-account-card,
+body.eink-client .quota-local-card {
+ color: #171714;
+ background: #f8f7f1;
+ border: 2px solid #3b3b34;
+ border-radius: 0;
+ box-shadow: none;
+}
+
+body.eink-client .side-title span,
+body.eink-client .side-kicker,
+body.eink-client .metric-sub,
+body.eink-client .quota-title span,
+body.eink-client .quota-status,
+body.eink-client .quota-provider-title,
+body.eink-client .quota-window span,
+body.eink-client .quota-window small,
+body.eink-client .quota-time,
+body.eink-client .quota-action-status,
+body.eink-client .quota-account-switcher {
+ color: #515148;
+}
+
+body.eink-client .side-big,
+body.eink-client .metric-value,
+body.eink-client .quota-window b,
+body.eink-client .quota-account-email,
+body.eink-client .quota-credit-row {
+ color: #171714;
+ font-weight: 800;
+}
+
+body.eink-client .brand-copy strong,
+body.eink-client .brand-copy span,
+body.eink-client .sideboard-shell .side-summary span,
+body.eink-client .sideboard-shell .feed-list li,
+body.eink-client .sideboard-shell .feed-list span,
+body.eink-client .sideboard-shell .metric-sub,
+body.eink-client .sideboard-shell .side-title span {
+ color: #3d3d36 !important;
+ text-shadow: none !important;
+}
+
+body.eink-client .brand-copy strong,
+body.eink-client .sideboard-shell .side-big,
+body.eink-client .sideboard-shell .metric-value {
+ color: #171714 !important;
+}
+
+body.eink-client .sideboard-shell .feed-heading {
+ color: #315b50 !important;
+}
+
+body.eink-client .mini-bar,
+body.eink-client .quota-bar {
+ height: 10px;
+ border: 1px solid #3b3b34;
+ border-radius: 0;
+ background: #d8d5ca;
+}
+
+body.eink-client .mini-bar span,
+body.eink-client .quota-bar i,
+body.eink-client .quota-dot.active {
+ border-radius: 0;
+ background: #315b50;
+}
+
+body.eink-client .quota-shell {
+ /* Keep dark-theme CSS vars from overwriting light e-ink ink. */
+ --quota-accent: #171714;
+ --quota-blue: #24241f;
+ --quota-ink: #171714;
+ --quota-muted: #3d3d36;
+ --quota-panel: #f8f7f1;
+ grid-template-rows: auto auto minmax(0, 1fr);
+ gap: 14px;
+ overflow-x: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+body.eink-client .quota-title strong {
+ font-size: clamp(25px, 3.2vw, 38px);
+ color: #171714;
+}
+
+body.eink-client .quota-help {
+ display: block;
+}
+
+body.eink-client .quota-help:empty {
+ display: none;
+}
+
+/* Help / setup copy must be high-contrast on cream paper; dark glass styles are unreadable. */
+body.eink-client .quota-help-block {
+ border: 3px solid #24241f;
+ border-radius: 0;
+ background: #fffef8;
+ color: #171714;
+ font-size: clamp(15px, 2.1vw, 18px);
+ line-height: 1.5;
+ font-weight: 600;
+ box-shadow: none;
+}
+
+body.eink-client .quota-help-summary {
+ padding: 12px 14px;
+ min-height: 48px;
+ color: #171714;
+ font-size: clamp(16px, 2.2vw, 20px);
+ font-weight: 800;
+}
+
+body.eink-client .quota-help-summary-hint {
+ border: 2px solid #24241f;
+ border-radius: 0;
+ background: #f2f0e8;
+ color: #171714;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+body.eink-client .quota-help-body {
+ padding: 0 14px 14px;
+ border-top: 2px solid #24241f;
+}
+
+body.eink-client .quota-help-block ol {
+ margin: 10px 0 0;
+ padding-left: 1.35em;
+}
+
+body.eink-client .quota-help-block li {
+ color: #171714;
+ font-weight: 600;
+}
+
+body.eink-client .quota-help-block li + li {
+ margin-top: 8px;
+}
+
+body.eink-client .quota-setup-card {
+ gap: 14px;
+ padding: 16px 18px;
+ border: 3px solid #24241f;
+ background: #fffef8;
+}
+
+body.eink-client .quota-setup-title {
+ color: #171714;
+ font-size: clamp(18px, 2.6vw, 24px);
+ font-weight: 800;
+}
+
+body.eink-client .quota-setup-list {
+ color: #24241f;
+ font-size: clamp(15px, 2.1vw, 18px);
+ font-weight: 600;
+ line-height: 1.5;
+}
+
+body.eink-client .quota-setup-list b {
+ color: #171714;
+ font-weight: 800;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+body.eink-client .quota-setup-card .quota-footer {
+ margin-top: 4px;
+ padding-top: 12px;
+ border-top: 2px solid #24241f;
+}
+
+body.eink-client .quota-setup-card .quota-time {
+ color: #171714 !important;
+ font-size: 14px;
+ font-weight: 800;
+}
+
+body.eink-client .quota-account-card {
+ gap: 16px;
+ padding: 18px;
+}
+
+body.eink-client .quota-window b {
+ font-size: clamp(18px, 2.4vw, 28px);
+}
+
+body.eink-client .quota-toolbox {
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+body.eink-client .quota-toolbox button,
+body.eink-client .quota-page-button {
+ width: 48px;
+ height: 48px;
+ min-height: 48px;
+ border: 3px solid #24241f;
+ border-radius: 2px;
+ color: #171714;
+ background: #f2f0e8;
+ font-size: 20px;
+ font-weight: 800;
+}
+
+@media (orientation: portrait), (max-width: 760px) {
+ body.eink-client .quota-pair {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ body.eink-client *,
+ body.eink-client *::before,
+ body.eink-client *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+}
+
+ *,
+ *::before,
+ *::after {
+ box-sizing: border-box;
+ }
+
+ body {
+ margin: 0;
+ min-height: 100vh;
+ display: grid;
+ grid-template-rows: auto 1fr;
+ overflow-x: hidden;
+ background:
+ radial-gradient(circle at 18% 0%, rgba(112, 217, 139, .08), transparent 32rem),
+ linear-gradient(180deg, #101820, #0d131a);
+ -webkit-user-select: none;
+ user-select: none;
+ }
+
+ header {
+ padding: calc(14px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(18px + var(--safe-area-inset-right, env(safe-area-inset-right))) 14px calc(18px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ background: rgba(15, 23, 32, .82);
+ backdrop-filter: blur(20px) saturate(140%);
+ -webkit-backdrop-filter: blur(20px) saturate(140%);
+ border-bottom: 1px solid rgba(143, 209, 255, .12);
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 14px 18px;
+ align-items: start;
+ box-shadow: 0 1px 0 rgba(143, 209, 255, .08), 0 18px 48px rgba(0, 0, 0, .32);
+ }
+
+ main {
+ display: grid;
+ padding: 16px;
+ padding-left: calc(16px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ padding-right: calc(16px + var(--safe-area-inset-right, env(safe-area-inset-right)));
+ padding-bottom: calc(16px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
+ min-height: 0;
+ }
+
+ .app-view {
+ display: none;
+ min-height: 0;
+ }
+
+ .app-view.active {
+ display: grid;
+ }
+
+ .display-view {
+ place-items: center;
+ position: relative;
+ }
+
+ .display-toolbar {
+ position: absolute;
+ z-index: 12;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ max-width: calc(100% - 20px);
+ padding: 7px;
+ border: 1px solid rgba(143, 209, 255, .24);
+ border-radius: 12px;
+ background: rgba(8, 14, 20, .84);
+ box-shadow: 0 8px 28px rgba(0, 0, 0, .38);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ }
+
+ .display-toolbar[hidden] {
+ display: none !important;
+ }
+
+ /* Collapsed: only a translucent gear. Expanded: source select + keyboard. */
+ .display-toolbar.is-collapsed {
+ gap: 0;
+ padding: 3px;
+ background: rgba(8, 14, 20, .28);
+ border-color: rgba(143, 209, 255, .12);
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .18);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ }
+
+ .display-toolbar.is-collapsed .display-toolbar-extras {
+ display: none !important;
+ }
+
+ .display-toolbar-toggle {
+ flex: 0 0 auto;
+ display: inline-grid;
+ place-items: center;
+ width: 36px;
+ height: 36px;
+ min-height: 36px;
+ min-width: 36px;
+ padding: 0;
+ border-radius: 999px;
+ border: 1px solid rgba(143, 209, 255, .22);
+ background: rgba(19, 29, 39, .42);
+ color: rgba(232, 241, 248, .78);
+ font-size: 18px;
+ line-height: 1;
+ cursor: pointer;
+ transition: background .18s ease, border-color .18s ease, color .18s ease, opacity .18s ease;
+ }
+
+ .display-toolbar.is-collapsed .display-toolbar-toggle {
+ width: 32px;
+ height: 32px;
+ min-width: 32px;
+ min-height: 32px;
+ background: rgba(19, 29, 39, .22);
+ border-color: rgba(143, 209, 255, .14);
+ color: rgba(232, 241, 248, .55);
+ opacity: .72;
+ }
+
+ .display-toolbar.is-collapsed .display-toolbar-toggle:hover,
+ .display-toolbar.is-collapsed .display-toolbar-toggle:active {
+ opacity: 1;
+ color: rgba(232, 241, 248, .92);
+ background: rgba(19, 29, 39, .45);
+ }
+
+ .display-toolbar-gear {
+ display: block;
+ transform: translateY(-0.5px);
+ }
+
+ .display-toolbar-toggle[aria-expanded="true"] {
+ color: #071017;
+ background: var(--green);
+ border-color: var(--green);
+ opacity: 1;
+ }
+
+ .display-toolbar-extras {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .display-source-control {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 800;
+ }
+
+ .display-source-control select {
+ width: min(50vw, 360px);
+ min-width: 150px;
+ min-height: 38px;
+ color: var(--text);
+ background: rgba(19, 29, 39, .96);
+ border-color: rgba(143, 209, 255, .25);
+ }
+
+ .display-source-kind {
+ flex: 0 0 auto;
+ padding: 5px 8px;
+ border-radius: 999px;
+ color: var(--green);
+ background: rgba(112, 217, 139, .1);
+ font-size: 11px;
+ font-weight: 900;
+ white-space: nowrap;
+ }
+
+ body.physical-display-source .display-source-kind {
+ color: var(--yellow);
+ background: rgba(255, 210, 122, .12);
+ }
+
+ .remote-keyboard-button {
+ flex: 0 0 auto;
+ min-height: 38px;
+ white-space: nowrap;
+ }
+
+ .remote-keyboard-button[aria-pressed="true"] {
+ color: #071017;
+ background: var(--green);
+ border-color: var(--green);
+ }
+
+ .remote-keyboard-input {
+ position: fixed;
+ z-index: -1;
+ left: 50%;
+ bottom: 0;
+ width: 2px;
+ height: 2px;
+ min-height: 0;
+ padding: 0;
+ opacity: .01;
+ border: 0;
+ resize: none;
+ font-size: 16px;
+ }
+
+ body.viewer-fullscreen.mode-display .display-toolbar {
+ position: fixed;
+ top: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top)));
+ }
+
+ .screen-media {
+ width: min(100%, 1280px);
+ aspect-ratio: 16 / 9;
+ object-fit: contain;
+ background: #05070a;
+ border: 1px solid rgba(143, 209, 255, .14);
+ border-radius: 8px;
+ touch-action: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+ -webkit-user-drag: none;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, .3), 0 12px 40px rgba(0, 0, 0, .5);
+ transition: box-shadow 0.3s ease;
+ }
+
+ .screen-media:hover {
+ box-shadow: 0 0 0 1px rgba(143, 209, 255, .2), 0 16px 48px rgba(0, 0, 0, .6);
+ }
+
+ .screen-media[hidden] {
+ display: none !important;
+ }
+
+ @media (max-width: 620px) {
+ .display-toolbar {
+ top: 6px;
+ gap: 5px;
+ padding: 5px;
+ }
+
+ .display-source-control > span,
+ .display-source-kind {
+ display: none;
+ }
+
+ .display-source-control select {
+ width: min(62vw, 300px);
+ min-width: 0;
+ }
+
+ .remote-keyboard-button {
+ padding-inline: 10px;
+ }
+ }
+
+ body.viewer-fullscreen {
+ background: #000;
+ }
+
+ body.viewer-fullscreen.mode-display {
+ grid-template-rows: 1fr;
+ width: var(--viewer-width);
+ height: var(--viewer-height);
+ overflow: hidden;
+ position: fixed;
+ inset: 0;
+ }
+
+ body.viewer-fullscreen > header {
+ display: none;
+ }
+
+ body.dashboard-viewer,
+ body.viewer-immersive {
+ background: #000;
+ grid-template-rows: 1fr;
+ width: 100dvw;
+ height: 100dvh;
+ min-height: 100dvh;
+ overflow: hidden;
+ position: fixed;
+ inset: 0;
+ }
+
+ body.eink-client.dashboard-viewer,
+ body.eink-client.viewer-immersive {
+ background: #f2f0e8;
+ }
+
+ body.dashboard-viewer > header,
+ body.viewer-immersive > header {
+ display: none !important;
+ }
+
+ body.deck-window {
+ grid-template-rows: 1fr;
+ width: 100vw;
+ height: 100vh;
+ min-height: 100vh;
+ overflow: hidden;
+ background: #000;
+ }
+
+ body.deck-window > header,
+ body.deck-window .exit-viewer {
+ display: none !important;
+ }
+
+ body.deck-window main {
+ width: 100vw;
+ height: 100vh;
+ min-height: 100vh;
+ padding: 0;
+ overflow: hidden;
+ background: #000;
+ }
+
+ body.deck-window .app-view.active {
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ justify-self: stretch;
+ align-self: stretch;
+ }
+
+ body.dashboard-viewer main,
+ body.viewer-immersive main {
+ padding: 0;
+ background: #000;
+ width: 100dvw;
+ height: 100dvh;
+ min-height: 100dvh;
+ max-height: 100dvh;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+
+ body.eink-client.dashboard-viewer main,
+ body.eink-client.viewer-immersive main {
+ background: #f2f0e8;
+ }
+
+ body.dashboard-viewer .app-view.active,
+ body.viewer-immersive .app-view.active {
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ min-height: 100%;
+ justify-self: stretch;
+ align-self: stretch;
+ }
+
+ body.viewer-fullscreen.mode-display main {
+ padding: 0;
+ background: #000;
+ width: var(--viewer-width);
+ height: var(--viewer-height);
+ overflow: hidden;
+ }
+
+ body.viewer-fullscreen.mode-display .screen-media {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ /* Letterbox instead of crop/stretch; keep full viewport box (safe-area is black margin). */
+ width: var(--viewer-width);
+ height: var(--viewer-height);
+ max-width: none;
+ aspect-ratio: auto;
+ object-fit: contain;
+ border: 0;
+ border-radius: 0;
+ transform: translate(-50%, -50%);
+ }
+
+ body.viewer-fullscreen.mode-display.rotate-90 .screen-media,
+ body.viewer-fullscreen.mode-display.rotate-270 .screen-media {
+ width: var(--viewer-height);
+ height: var(--viewer-width);
+ }
+
+ .exit-viewer {
+ position: fixed;
+ right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right)));
+ bottom: calc(10px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
+ z-index: 10;
+ display: none;
+ min-width: 42px;
+ min-height: 42px;
+ padding: 0;
+ border-radius: 999px;
+ background: rgba(10, 14, 18, 0.66);
+ border-color: rgba(255, 255, 255, 0.28);
+ backdrop-filter: blur(8px);
+ }
+
+ body.viewer-fullscreen .exit-viewer,
+ body.dashboard-viewer .exit-viewer {
+ display: inline-grid;
+ place-items: center;
+ }
+
+ body.rotate-90 .screen-media {
+ transform: rotate(90deg);
+ }
+
+ body.viewer-fullscreen.mode-display.rotate-90 .screen-media {
+ transform: translate(-50%, -50%) rotate(90deg);
+ }
+
+ body.rotate-180 .screen-media {
+ transform: rotate(180deg);
+ }
+
+ body.viewer-fullscreen.mode-display.rotate-180 .screen-media {
+ transform: translate(-50%, -50%) rotate(180deg);
+ }
+
+ body.rotate-270 .screen-media {
+ transform: rotate(270deg);
+ }
+
+ body.viewer-fullscreen.mode-display.rotate-270 .screen-media {
+ transform: translate(-50%, -50%) rotate(270deg);
+ }
+
+ body.viewer-fullscreen.mode-sideboard,
+ body.viewer-fullscreen.mode-quota,
+ body.dashboard-viewer.mode-sideboard,
+ body.dashboard-viewer.mode-quota {
+ grid-template-rows: 1fr;
+ min-height: var(--viewer-height);
+ }
+
+ body.viewer-fullscreen.mode-sideboard main,
+ body.viewer-fullscreen.mode-quota main,
+ body.dashboard-viewer.mode-sideboard main,
+ body.dashboard-viewer.mode-quota main,
+ body.deck-window.mode-sideboard main,
+ body.deck-window.mode-quota main {
+ padding: 0;
+ background: #000;
+ width: 100%;
+ min-height: var(--viewer-height);
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+
+ .brand {
+ order: 1;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ }
+
+ .brand-mark {
+ display: inline-grid;
+ place-items: center;
+ width: 38px;
+ height: 38px;
+ border: 1px solid rgba(112, 217, 139, .52);
+ border-radius: 8px;
+ background: #0d131a;
+ color: #70d98b;
+ font-family: "Share Tech Mono", Consolas, monospace;
+ font-size: 19px;
+ font-weight: 800;
+ line-height: 1;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08), 0 0 12px rgba(112, 217, 139, .18);
+ transition: box-shadow 0.3s ease;
+ }
+
+ .brand-mark:hover {
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 0 24px rgba(112, 217, 139, .36);
+ }
+
+ .brand-copy {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+ }
+
+ .brand-copy strong {
+ color: var(--text);
+ font-size: 19px;
+ line-height: 1.05;
+ }
+
+ .brand-copy span {
+ color: var(--quiet);
+ font-size: 12px;
+ line-height: 1.25;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .host-version-label {
+ font-style: normal;
+ font-weight: 600;
+ opacity: 0.75;
+ margin-left: 0.35em;
+ }
+
+ .product-update-status {
+ max-width: 210px;
+ color: var(--quiet);
+ font-size: 13px;
+ line-height: 1.3;
+ }
+
+ .controls,
+ .view-switcher,
+ .utility-controls {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ min-width: 0;
+ }
+
+ .top-controls {
+ order: 2;
+ display: grid;
+ grid-template-columns: auto auto;
+ gap: 10px;
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ .view-switcher {
+ flex-wrap: nowrap;
+ padding: 4px;
+ border: 1px solid rgba(255, 255, 255, .08);
+ border-radius: 8px;
+ background: rgba(9, 14, 20, .36);
+ }
+
+ .view-switcher .mode-button {
+ min-height: 34px;
+ border: 0;
+ background: transparent;
+ color: var(--muted);
+ }
+
+ .utility-controls {
+ justify-content: flex-end;
+ min-width: 0;
+ }
+
+ .locale-control {
+ display: inline-flex;
+ align-items: center;
+ }
+
+ .locale-control select {
+ min-height: 34px;
+ max-width: 136px;
+ padding: 0 8px;
+ border: 1px solid rgba(143, 209, 255, .22);
+ border-radius: 7px;
+ background: rgba(9, 14, 20, .55);
+ color: var(--text);
+ font: inherit;
+ font-size: 12px;
+ }
+
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ #displaySettingsToggle {
+ display: none;
+ }
+
+ .display-empty-state {
+ min-height: min(52dvh, 430px);
+ width: min(100%, 720px);
+ box-sizing: border-box;
+ display: grid;
+ place-content: center;
+ justify-items: center;
+ gap: 10px;
+ padding: 32px;
+ border: 1px dashed rgba(143, 209, 255, .24);
+ border-radius: 14px;
+ background: linear-gradient(145deg, rgba(18, 29, 39, .88), rgba(8, 13, 19, .92));
+ color: var(--muted);
+ text-align: center;
+ }
+
+ .display-empty-state[hidden] { display: none; }
+ .display-empty-state strong { color: var(--text); font-size: 22px; }
+ .display-empty-state > span:not(.display-empty-icon) { max-width: 440px; line-height: 1.5; }
+ .display-empty-icon { color: var(--blue); font-size: 42px; line-height: 1; }
+ .display-empty-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 6px; }
+ .display-empty-state button { min-width: 170px; background: var(--blue); color: #071017; font-weight: 800; }
+ .display-empty-state button.secondary { background: transparent; color: var(--text); border-color: var(--border); }
+ .display-empty-state button:disabled { cursor: wait; opacity: .65; }
+ .display-install-detail { min-height: 1.4em; max-width: 520px; color: var(--muted); line-height: 1.45; }
+ body.display-unavailable .display-view .screen-media { display: none !important; }
+ body.display-unavailable .driver-state { display: none; }
+
+ .mode-button.active {
+ background: var(--text);
+ color: var(--surface);
+ border-color: var(--text);
+ box-shadow: 0 0 10px rgba(143, 209, 255, .22);
+ }
+
+ .view-switcher .mode-button {
+ transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
+ }
+
+ .status {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--muted);
+ font-size: 14px;
+ min-height: 34px;
+ padding: 0 10px;
+ border: 1px solid rgba(255, 255, 255, .08);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, .035);
+ }
+
+ .dot {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #d64545;
+ flex-shrink: 0;
+ }
+
+ .dot.online {
+ background: #27ae60;
+ box-shadow: 0 0 0 0 rgba(39, 174, 96, 0.5);
+ animation: dot-pulse 2.4s ease-out infinite;
+ }
+
+ @keyframes dot-pulse {
+ 0% { box-shadow: 0 0 0 0 rgba(39, 174, 96, 0.55); }
+ 70% { box-shadow: 0 0 0 7px rgba(39, 174, 96, 0); }
+ 100% { box-shadow: 0 0 0 0 rgba(39, 174, 96, 0); }
+ }
+
+ .panel {
+ order: 4;
+ grid-column: 1 / -1;
+ border-top: 1px solid rgba(255, 255, 255, .07);
+ padding-top: 10px;
+ }
+
+ .panel summary {
+ cursor: pointer;
+ color: #dce7f4;
+ font-size: 14px;
+ margin-bottom: 8px;
+ }
+
+ /* Setup-time controls (display / stream / resolution) collapse into a compact
+ row so the everyday top bar stays slim and the actual view can breathe. */
+ .setup-panels {
+ order: 6;
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ border-top: 1px solid rgba(255, 255, 255, .07);
+ padding-top: 10px;
+ }
+
+ .setup-panels .panel {
+ order: 0;
+ grid-column: auto;
+ border-top: 0;
+ padding-top: 0;
+ flex: 0 1 auto;
+ border: 1px solid rgba(255, 255, 255, .08);
+ border-radius: 8px;
+ background: rgba(9, 14, 20, .36);
+ }
+
+ .setup-panels .panel:not([open]) {
+ padding: 0;
+ }
+
+ .setup-panels .panel summary {
+ margin: 0;
+ padding: 7px 12px;
+ list-style: none;
+ font-size: 13px;
+ color: var(--muted);
+ user-select: none;
+ }
+
+ .setup-panels .panel summary::-webkit-details-marker { display: none; }
+ .setup-panels .panel summary::before { content: "⚙ "; opacity: .6; }
+
+ .setup-panels .panel[open] {
+ flex-basis: 100%;
+ background: rgba(8, 13, 19, .5);
+ }
+
+ .setup-panels .panel[open] summary {
+ color: #dce7f4;
+ border-bottom: 1px solid rgba(255, 255, 255, .07);
+ margin-bottom: 8px;
+ }
+
+ .setup-panels .panel .controls {
+ padding: 0 12px 12px;
+ }
+
+ .field {
+ display: inline-grid;
+ gap: 4px;
+ font-size: 12px;
+ color: var(--quiet);
+ }
+
+ .device-management-panel {
+ order: 3;
+ grid-column: 1 / -1;
+ }
+
+ .new-device-panel,
+ .diagnostics-panel {
+ order: 4;
+ grid-column: 1 / -1;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: rgba(8, 13, 19, .28);
+ }
+
+ .diagnostics-panel {
+ order: 7;
+ }
+
+ .new-device-panel[hidden],
+ .diagnostics-panel[hidden] {
+ display: none;
+ }
+
+ .new-device-panel > summary,
+ .diagnostics-panel > .diagnostics-toggle {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 2px 12px;
+ align-items: center;
+ width: 100%;
+ padding: 11px 13px;
+ border: 0;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ list-style: none;
+ text-align: left;
+ user-select: none;
+ }
+
+ .new-device-panel > summary::-webkit-details-marker,
+ .diagnostics-panel > .diagnostics-toggle::-webkit-details-marker {
+ display: none;
+ }
+
+ .new-device-panel > summary span,
+ .diagnostics-panel > .diagnostics-toggle span {
+ color: #e8f2ff;
+ font-size: 14px;
+ font-weight: 800;
+ }
+
+ .new-device-panel > summary small,
+ .diagnostics-panel > .diagnostics-toggle small {
+ grid-column: 1;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.35;
+ }
+
+ .new-device-panel > summary::after,
+ .diagnostics-panel > .diagnostics-toggle::after {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ color: var(--muted);
+ content: "+";
+ font-size: 18px;
+ line-height: 1;
+ }
+
+ .new-device-panel[open] > summary::after,
+ .diagnostics-panel.is-open > .diagnostics-toggle::after {
+ content: "−";
+ }
+
+ .new-device-panel[open] > summary,
+ .diagnostics-panel.is-open > .diagnostics-toggle {
+ border-bottom: 1px solid rgba(255,255,255,.08);
+ }
+
+ .new-device-panel-content,
+ .diagnostics-panel-content {
+ padding: 12px;
+ }
+
+ .new-device-panel .connect-panel {
+ order: initial;
+ grid-column: auto;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ }
+
+ .diagnostics-panel-content {
+ display: grid;
+ gap: 10px;
+ }
+
+ .diagnostics-panel.is-open {
+ display: flex;
+ flex-direction: column;
+ height: 260px;
+ height: min(34dvh, 260px);
+ }
+
+ .diagnostics-panel.is-open .diagnostics-panel-content {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ .diagnostics-panel-content > p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.45;
+ }
+
+ .diagnostics-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .diagnostics-actions button,
+ .diagnostics-actions a {
+ min-height: 32px;
+ padding: 6px 10px;
+ font-size: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ }
+
+ .diagnostics-actions a {
+ border: 1px solid var(--line);
+ color: var(--text);
+ background: var(--panel);
+ }
+
+html[data-device-preview],
+html[data-device-preview] body {
+ scrollbar-width: none;
+}
+
+html[data-device-preview]::-webkit-scrollbar,
+html[data-device-preview] body::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+}
+
+ .diagnostics-list {
+ display: grid;
+ gap: 7px;
+ max-height: 340px;
+ overflow: auto;
+ padding-right: 2px;
+ }
+
+ .diagnostics-panel.is-open .diagnostics-list {
+ flex: 1 1 auto;
+ min-height: 0;
+ max-height: none;
+ }
+
+ .diagnostics-entry,
+ .diagnostics-empty,
+ .diagnostics-storage-error {
+ display: grid;
+ gap: 3px;
+ padding: 8px 9px;
+ border: 1px solid rgba(255,255,255,.09);
+ border-radius: 6px;
+ background: rgba(10,16,24,.28);
+ color: #d7e1eb;
+ font-size: 12px;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+ }
+
+ .diagnostics-entry.is-warning,
+ .diagnostics-storage-error {
+ border-color: rgba(255, 210, 122, .42);
+ }
+
+ .diagnostics-entry.is-error {
+ border-color: rgba(255, 133, 133, .5);
+ background: rgba(92, 28, 34, .24);
+ }
+
+ .diagnostics-entry strong {
+ color: #f4f8fc;
+ font-size: 12px;
+ }
+
+ .diagnostics-entry-meta,
+ .diagnostics-entry-subject,
+ .diagnostics-entry-details,
+ .diagnostics-empty,
+ .diagnostics-storage-error {
+ color: #b9c4d0;
+ }
+
+ .connect-panel {
+ order: initial;
+ grid-column: auto;
+ display: grid;
+ grid-template-columns: minmax(230px, 280px) minmax(0, 1fr);
+ gap: 18px;
+ align-items: start;
+ padding: 16px;
+ background:
+ linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.018)),
+ rgba(8, 13, 19, .28);
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ }
+
+ .connect-qr {
+ display: grid;
+ grid-template-rows: auto auto;
+ gap: 10px;
+ align-items: start;
+ align-content: start;
+ justify-items: center;
+ min-width: 0;
+ padding: 12px;
+ border: 1px solid rgba(255,255,255,.08);
+ border-radius: 8px;
+ background: rgba(8, 12, 18, .36);
+ }
+
+ .connect-qr span {
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.35;
+ text-align: center;
+ }
+
+ .connect-panel img {
+ width: 100%;
+ max-width: 252px;
+ aspect-ratio: 1;
+ background: #fff;
+ border-radius: 7px;
+ padding: 10px;
+ box-sizing: border-box;
+ align-self: start;
+ }
+
+ .connect-copy {
+ display: grid;
+ gap: 14px;
+ min-width: 0;
+ color: var(--muted);
+ font-size: 13px;
+ }
+
+ .connect-title {
+ display: grid;
+ gap: 3px;
+ }
+
+ .connect-title strong {
+ color: var(--text);
+ font-size: 22px;
+ line-height: 1.05;
+ }
+
+ .connect-title span {
+ color: var(--muted);
+ line-height: 1.35;
+ }
+
+ .connect-actions {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
+ gap: 8px;
+ align-items: center;
+ }
+
+ .connect-actions a,
+ .connect-actions button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 40px;
+ padding: 9px 13px;
+ border-radius: 6px;
+ border: 1px solid #3a4a5f;
+ background: var(--surface-3);
+ color: var(--text);
+ text-decoration: none;
+ white-space: nowrap;
+ font-weight: 600;
+ transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.18s ease;
+ }
+
+ .connect-actions a:hover,
+ .connect-actions button:hover {
+ background: var(--surface-2);
+ border-color: rgba(143, 209, 255, .4);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .25);
+ }
+
+ .connect-actions [hidden],
+ .connect-link-list [hidden] {
+ display: none;
+ }
+
+ .certificate-install-help {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.45;
+ }
+
+ .certificate-install-help[hidden] { display: none; }
+
+ .connect-actions .primary-action {
+ background: var(--text);
+ color: var(--surface);
+ border-color: var(--text);
+ font-weight: 750;
+ }
+
+ .connect-actions .primary-action:hover {
+ background: #ffffff;
+ border-color: #ffffff;
+ box-shadow: 0 0 18px rgba(245, 247, 251, .28), 0 4px 14px rgba(0, 0, 0, .25);
+ }
+
+ .device-connection-code {
+ display: grid;
+ gap: 10px;
+ padding: 13px;
+ border: 1px solid rgba(141, 230, 166, .34);
+ border-radius: 8px;
+ background: rgba(47, 207, 114, .06);
+ }
+
+ .device-connection-code[hidden] { display: none; }
+
+ .device-connection-code strong {
+ color: #dff8e6;
+ font-size: 15px;
+ }
+
+ .device-connection-code p,
+ .device-connection-code small {
+ margin: 3px 0 0;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+ }
+
+ .device-connection-code p code {
+ color: #dff8e6;
+ font-family: var(--mono);
+ font-size: 1em;
+ user-select: all;
+ }
+
+ .device-connection-code-value {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ align-items: center;
+ }
+
+ .device-connection-code output {
+ min-width: 184px;
+ min-height: 1.2em;
+ color: #ffffff;
+ font-family: var(--mono);
+ font-size: clamp(24px, 2.4vw, 34px);
+ font-weight: 800;
+ letter-spacing: .11em;
+ line-height: 1.2;
+ overflow: visible;
+ }
+
+ .device-connection-code button {
+ min-height: 38px;
+ padding: 8px 12px;
+ border: 1px solid rgba(141, 230, 166, .42);
+ border-radius: 6px;
+ background: rgba(47, 207, 114, .12);
+ color: #dff8e6;
+ font-weight: 700;
+ }
+
+ body.phone-client .device-connection-code { display: none !important; }
+
+ .pairing-guide {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 10px;
+ }
+
+ .pairing-progress {
+ display: grid;
+ gap: 7px;
+ min-width: 0;
+ }
+
+ .pairing-progress > div {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--muted);
+ font-size: 12px;
+ }
+
+ .pairing-progress > div strong {
+ color: #dce7f4;
+ font-size: 12px;
+ }
+
+ .pairing-progress > div b {
+ color: #8fd1ff;
+ font-family: var(--mono);
+ }
+
+ .pairing-progress progress {
+ width: 100%;
+ height: 9px;
+ overflow: hidden;
+ border: 0;
+ border-radius: 999px;
+ background: rgba(255,255,255,.08);
+ appearance: none;
+ }
+
+ .pairing-progress progress::-webkit-progress-bar {
+ background: rgba(255,255,255,.08);
+ border-radius: 999px;
+ }
+
+ .pairing-progress progress::-webkit-progress-value {
+ background: linear-gradient(90deg, #579fd4, #8fd1ff);
+ border-radius: 999px;
+ transition: width .25s ease;
+ }
+
+ .pairing-progress.idle > div {
+ justify-content: flex-start;
+ }
+
+ .pairing-progress.idle [data-pair-progress-label],
+ .pairing-progress.idle progress {
+ display: none;
+ }
+
+ .pairing-progress.complete progress::-webkit-progress-value {
+ background: #2fcf72;
+ }
+
+ .pairing-progress.complete > div b,
+ .pairing-progress.complete > div strong {
+ color: #8de6a6;
+ }
+
+ .pairing-step {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 6px 9px;
+ align-items: start;
+ min-width: 0;
+ min-height: 76px;
+ padding: 12px;
+ border: 1px solid rgba(255,255,255,.08);
+ border-radius: 7px;
+ background: rgba(255,255,255,.035);
+ }
+
+ .pairing-step b {
+ display: inline-grid;
+ place-items: center;
+ width: 22px;
+ height: 22px;
+ border-radius: 999px;
+ background: var(--surface-3);
+ color: #dce7f4;
+ font-size: 12px;
+ }
+
+ .pairing-step strong {
+ color: #f5f7fb;
+ font-size: 14px;
+ line-height: 1.25;
+ }
+
+ .pairing-step span {
+ grid-column: 2;
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.35;
+ }
+
+ .pairing-step.active {
+ border-color: rgba(143,209,255,.34);
+ background: rgba(143,209,255,.08);
+ }
+
+ .pairing-step.done b {
+ background: #2fcf72;
+ color: #102018;
+ }
+
+ .connect-status {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 6px 10px;
+ padding: 10px;
+ border-radius: 6px;
+ background: rgba(255,255,255,.035);
+ }
+
+ .connect-status span {
+ min-width: 0;
+ line-height: 1.35;
+ }
+
+ .connect-status span:empty {
+ display: none;
+ }
+
+ .connect-links {
+ border-top: 1px solid rgba(255,255,255,.08);
+ padding-top: 10px;
+ }
+
+ .connect-links summary {
+ cursor: pointer;
+ color: #dce7f4;
+ font-size: 13px;
+ list-style-position: inside;
+ }
+
+ .connect-link-list {
+ display: grid;
+ gap: 6px;
+ margin-top: 8px;
+ }
+
+ .advanced-cert-title {
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255,255,255,.08);
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+ }
+
+ .public-endpoint-panel {
+ display: grid;
+ gap: 8px;
+ margin-top: 8px;
+ }
+
+ .public-endpoint-panel label,
+ .public-endpoint-panel small,
+ .public-endpoint-status,
+ .public-endpoint-id {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.45;
+ }
+
+ .public-endpoint-status,
+ .public-endpoint-id {
+ margin: 0;
+ }
+
+ .public-endpoint-id code {
+ color: #dce7f4;
+ font-size: 12px;
+ user-select: all;
+ }
+
+ .public-endpoint-controls {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ gap: 6px;
+ }
+
+ .public-endpoint-controls input {
+ min-width: 0;
+ min-height: 34px;
+ border: 1px solid rgba(255,255,255,.16);
+ border-radius: 6px;
+ padding: 6px 8px;
+ color: #eef6ff;
+ background: rgba(0,0,0,.22);
+ font: inherit;
+ font-size: 12px;
+ }
+
+ .public-endpoint-controls button {
+ min-height: 34px;
+ white-space: nowrap;
+ }
+
+ body.phone-client .public-endpoint-panel {
+ display: none !important;
+ }
+
+ .connect-copy a {
+ color: var(--blue);
+ overflow-wrap: anywhere;
+ }
+
+ .driver-state,
+ .wake-state,
+ .trust-state,
+ .device-state,
+ .app-state {
+ color: var(--muted);
+ font-size: 13px;
+ grid-column: 1 / -1;
+ }
+
+ .driver-state {
+ order: 5;
+ }
+
+ .wake-state.good {
+ color: #8de6a6;
+ }
+
+ .wake-state.warn {
+ color: #ffd27a;
+ }
+
+ .app-state.good {
+ color: #8de6a6;
+ }
+
+ .app-state.warn {
+ color: #ffd27a;
+ }
+
+ .stream-capability-state.good {
+ color: #8de6a6;
+ }
+
+ .stream-capability-state.warn {
+ color: #ffd27a;
+ }
+
+ .trust-state.good {
+ color: #8de6a6;
+ }
+
+ .trust-state.warn {
+ color: #ffd27a;
+ }
+
+ .trusted-devices {
+ display: grid;
+ gap: 8px;
+ margin-top: 2px;
+ padding: 10px;
+ border: 1px solid rgba(143,209,255,.18);
+ border-radius: 8px;
+ background: rgba(255,255,255,.035);
+ }
+
+ .trusted-devices[hidden] {
+ display: none;
+ }
+
+ .trusted-devices-head {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto auto;
+ gap: 8px;
+ align-items: center;
+ }
+
+ .trusted-devices-head strong {
+ color: #e8f2ff;
+ font-size: 13px;
+ }
+
+ /* Icon-only controls in the device head. Do NOT apply this to labeled
+ buttons (+ Add) — fixed 30×30 + padding:0 caused text breakout. */
+ .trusted-devices-head #refreshTrustedDevices,
+ .trusted-devices-head .device-management-more > summary {
+ display: grid;
+ place-items: center;
+ min-width: 30px;
+ width: 30px;
+ height: 30px;
+ min-height: 30px;
+ padding: 0;
+ border-radius: 999px;
+ }
+
+ /* Labeled actions: width follows content in every locale. */
+ .trusted-devices-head #openNewDevicePanel {
+ min-width: 0;
+ width: max-content;
+ max-width: 100%;
+ height: 30px;
+ min-height: 30px;
+ padding: 0 11px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1;
+ white-space: nowrap;
+ letter-spacing: 0;
+ }
+
+ .trusted-devices-head button[data-clear-trusted-devices] {
+ border-color: rgba(255, 115, 115, .5);
+ border-radius: 6px;
+ color: #ffb4b4;
+ font-size: 12px;
+ }
+
+ .device-management-more {
+ position: relative;
+ }
+
+ .device-management-more > summary {
+ border: 1px solid #3a4a5f;
+ background: #223040;
+ color: var(--text);
+ cursor: pointer;
+ font-size: 18px;
+ line-height: 1;
+ list-style: none;
+ }
+
+ .device-management-more > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .device-management-more > div {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ z-index: 20;
+ min-width: max(150px, max-content);
+ max-width: min(280px, 80vw);
+ padding: 6px;
+ border: 1px solid rgba(255,255,255,.16);
+ border-radius: 8px;
+ background: #111922;
+ box-shadow: 0 12px 30px rgba(0,0,0,.35);
+ box-sizing: border-box;
+ }
+
+ .device-management-more > div button {
+ width: 100%;
+ height: auto;
+ min-height: 34px;
+ min-width: 0;
+ padding: 7px 10px;
+ white-space: normal;
+ text-align: left;
+ line-height: 1.25;
+ overflow-wrap: anywhere;
+ }
+
+ .trusted-device-list {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+ }
+
+ .trusted-device-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ min-width: 0;
+ padding: 8px;
+ border: 1px solid rgba(255,255,255,.09);
+ border-radius: 6px;
+ background: rgba(10,16,24,.28);
+ }
+
+ .trusted-device-main {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ }
+
+ .trusted-device-name-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .trusted-device-name-row strong {
+ min-width: 0;
+ flex: 1 1 auto;
+ }
+
+ .trusted-device-status {
+ flex: 0 0 auto;
+ padding: 2px 7px;
+ border: 1px solid rgba(255,255,255,.18);
+ border-radius: 999px;
+ color: #8d9aa8;
+ font-size: 11px;
+ line-height: 1.2;
+ }
+
+ .trusted-device-row.is-connected {
+ border-color: rgba(112, 217, 139, .58);
+ background: rgba(24, 72, 45, .28);
+ }
+
+ .trusted-device-row.is-connected .trusted-device-status {
+ border-color: #70d98b;
+ color: #baf4c9;
+ font-weight: 800;
+ }
+
+ .trusted-device-main strong,
+ .trusted-device-main span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .trusted-device-main strong {
+ color: #f5f7fb;
+ font-size: 13px;
+ }
+
+ .trusted-device-main span,
+ .trusted-devices-empty {
+ color: #b9c4d0;
+ font-size: 12px;
+ }
+
+ .trusted-device-row button {
+ padding: 6px 9px;
+ font-size: 12px;
+ }
+
+
+ select,
+ button,
+ input {
+ background: #223040;
+ color: var(--text);
+ border: 1px solid #3a4a5f;
+ border-radius: 6px;
+ padding: 8px 10px;
+ font: inherit;
+ max-width: 100%;
+ min-width: 0;
+ }
+
+ input {
+ width: 88px;
+ }
+
+ #streamFps,
+ #streamQuality {
+ width: 72px;
+ }
+
+ button {
+ cursor: pointer;
+ }
+
+ .sideboard-view {
+ width: min(100%, 920px);
+ justify-self: center;
+ align-self: center;
+ }
+
+ .quota-view {
+ width: min(100%, 860px);
+ justify-self: center;
+ align-self: center;
+ min-height: 0;
+ }
+
+ .quota-shell {
+ --quota-accent: #70d98b;
+ --quota-blue: #66c5e8;
+ --quota-ink: #eff7f1;
+ --quota-muted: rgba(239,247,241,.62);
+ --quota-panel: rgba(14, 22, 23, .76);
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 12px;
+ width: 100%;
+ min-height: 360px;
+ padding: 12px;
+ overflow: hidden;
+ color: var(--quota-ink);
+ background:
+ linear-gradient(135deg, rgba(6, 12, 15, .92), rgba(20, 27, 25, .94)),
+ url("/sideboard-skins/command.png") center / cover;
+ background-blend-mode: multiply;
+ border: 1px solid rgba(255,255,255,.14);
+ border-radius: 8px;
+ }
+
+ .quota-top {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 10px;
+ align-items: end;
+ }
+
+ .quota-left {
+ display: grid;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .quota-title {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+ white-space: nowrap;
+ }
+
+ .quota-title strong {
+ font-size: clamp(18px, 3.6vw, 28px);
+ line-height: 1;
+ }
+
+ .quota-title span,
+ .quota-status {
+ color: var(--quota-muted);
+ font-size: clamp(10px, 1.45vw, 13px);
+ }
+
+ .quota-tabs {
+ display: flex;
+ gap: 5px;
+ min-width: 0;
+ overflow-x: auto;
+ scrollbar-width: none;
+ }
+
+ .quota-tabs::-webkit-scrollbar {
+ display: none;
+ }
+
+ .quota-tab {
+ min-height: 28px;
+ padding: 5px 11px;
+ border: 1px solid rgba(255,255,255,.12);
+ border-radius: 999px;
+ background: rgba(255,255,255,.055);
+ color: rgba(239,247,241,.72);
+ font-size: 12px;
+ font-weight: 800;
+ line-height: 1;
+ white-space: nowrap;
+ }
+
+ .quota-tab.active {
+ border-color: rgba(112,217,139,.48);
+ background: rgba(112,217,139,.2);
+ color: var(--quota-ink);
+ }
+
+ .quota-help {
+ min-width: 0;
+ }
+
+ .quota-help:empty {
+ display: none;
+ }
+
+ .quota-help-block {
+ border: 1px solid rgba(112, 217, 139, .22);
+ border-radius: 8px;
+ background: rgba(8, 14, 16, .72);
+ color: var(--quota-muted);
+ font-size: clamp(11px, 1.5vw, 13px);
+ line-height: 1.45;
+ overflow: hidden;
+ }
+
+ .quota-help-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-height: 36px;
+ padding: 8px 12px;
+ cursor: pointer;
+ list-style: none;
+ color: var(--quota-ink);
+ font-size: clamp(12px, 1.7vw, 14px);
+ font-weight: 800;
+ user-select: none;
+ }
+
+ .quota-help-summary::-webkit-details-marker {
+ display: none;
+ }
+
+ .quota-help-summary::after {
+ content: "▸";
+ flex: 0 0 auto;
+ color: var(--quota-muted);
+ font-size: 12px;
+ transition: transform .12s ease;
+ }
+
+ .quota-help-block[open] > .quota-help-summary::after {
+ transform: rotate(90deg);
+ }
+
+ .quota-help-summary-label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .quota-help-summary-hint {
+ flex: 0 0 auto;
+ padding: 2px 7px;
+ border: 1px solid rgba(112, 217, 139, .35);
+ border-radius: 999px;
+ background: rgba(112, 217, 139, .12);
+ color: var(--quota-accent);
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: .04em;
+ text-transform: uppercase;
+ }
+
+ .quota-help-body {
+ padding: 0 12px 10px;
+ border-top: 1px solid rgba(255, 255, 255, .08);
+ }
+
+ .quota-help-block ol {
+ margin: 8px 0 0;
+ padding-left: 1.2em;
+ }
+
+ .quota-help-block li + li {
+ margin-top: 4px;
+ }
+
+ body.eink-client .quota-help-summary::after {
+ transition: none;
+ }
+
+ .quota-setup-card {
+ display: grid;
+ gap: 10px;
+ align-content: start;
+ }
+
+ .quota-setup-title {
+ font-size: clamp(15px, 2.4vw, 18px);
+ font-weight: 800;
+ color: var(--quota-ink);
+ }
+
+ .quota-setup-list {
+ margin: 0;
+ padding-left: 1.15em;
+ color: var(--quota-muted);
+ font-size: clamp(11px, 1.5vw, 13px);
+ line-height: 1.45;
+ }
+
+ .quota-setup-list li + li {
+ margin-top: 4px;
+ }
+
+ .quota-setup-list b {
+ color: var(--quota-accent);
+ }
+
+ .quota-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 8px;
+ min-height: 0;
+ align-content: start;
+ }
+
+ .quota-account-page {
+ display: grid;
+ min-height: 0;
+ }
+
+ .quota-account-switcher {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+ justify-self: end;
+ color: var(--quota-muted);
+ font-size: 11px;
+ font-weight: 800;
+ white-space: nowrap;
+ }
+
+ .quota-page-button {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ min-height: 24px;
+ padding: 0;
+ border: 1px solid rgba(255,255,255,.12);
+ border-radius: 999px;
+ background: rgba(255,255,255,.06);
+ color: var(--quota-ink);
+ font-size: 15px;
+ line-height: 1;
+ }
+
+ .quota-dots {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ }
+
+ .quota-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 999px;
+ background: rgba(239,247,241,.34);
+ }
+
+ .quota-dot.active {
+ width: 14px;
+ background: var(--quota-accent);
+ }
+
+ .quota-account-card {
+ display: grid;
+ grid-template-rows: auto auto auto auto auto;
+ gap: 12px;
+ min-height: 0;
+ padding: 14px;
+ border: 1px solid rgba(255,255,255,.12);
+ background:
+ linear-gradient(135deg, rgba(255,255,255,.08), rgba(255,255,255,.025)),
+ var(--quota-panel);
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
+ overflow: hidden;
+ }
+
+ .quota-account-head {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
+ gap: 10px;
+ align-items: center;
+ min-width: 0;
+ }
+
+ .quota-check {
+ width: 15px;
+ height: 15px;
+ border: 1px solid rgba(239,247,241,.72);
+ background: rgba(255,255,255,.05);
+ }
+
+ .quota-account-email {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: clamp(13px, 2vw, 17px);
+ font-weight: 800;
+ }
+
+ .quota-pill {
+ padding: 3px 8px;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #2f9cea, #176ed4);
+ color: #fff;
+ font-size: 11px;
+ font-weight: 800;
+ line-height: 1;
+ white-space: nowrap;
+ }
+
+ .quota-active-badge {
+ padding: 3px 7px;
+ border-radius: 999px;
+ background: rgba(70, 210, 137, .18);
+ border: 1px solid rgba(112, 236, 166, .55);
+ color: #8ff0b6;
+ font-size: 10px;
+ font-weight: 800;
+ white-space: nowrap;
+ }
+
+ .quota-pair {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 14px;
+ min-height: 0;
+ }
+
+ .quota-pair.single {
+ grid-template-columns: 1fr;
+ }
+
+ .quota-provider {
+ display: grid;
+ grid-template-rows: auto auto auto;
+ gap: 10px;
+ min-width: 0;
+ }
+
+ .quota-provider-title {
+ color: rgba(239,247,241,.9);
+ font-size: clamp(12px, 1.8vw, 15px);
+ font-weight: 800;
+ }
+
+ .quota-window {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto auto auto;
+ gap: 5px 8px;
+ min-width: 0;
+ }
+
+ .quota-window span {
+ color: rgba(239,247,241,.72);
+ font-size: clamp(10px, 1.45vw, 12px);
+ font-weight: 700;
+ }
+
+ .quota-window b {
+ color: var(--quota-accent);
+ font-size: clamp(13px, 2vw, 18px);
+ line-height: 1;
+ letter-spacing: 0;
+ white-space: nowrap;
+ }
+
+ .quota-bar {
+ grid-column: 1 / -1;
+ height: 5px;
+ background: rgba(255,255,255,.14);
+ overflow: hidden;
+ border-radius: 999px;
+ }
+
+ .quota-bar i {
+ display: block;
+ width: 0%;
+ height: 100%;
+ background: var(--quota-accent);
+ border-radius: inherit;
+ }
+
+ .quota-window small {
+ grid-column: 1 / -1;
+ color: var(--quota-muted);
+ font-size: clamp(9px, 1.25vw, 11px);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .quota-credit-row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ color: rgba(239,247,241,.84);
+ font-size: 17px;
+ line-height: 1.2;
+ font-weight: 800;
+ }
+
+ .quota-footer {
+ display: grid;
+ grid-template-columns: minmax(0, auto) 1fr auto;
+ gap: 10px;
+ align-items: center;
+ padding-top: 10px;
+ border-top: 1px dashed rgba(255,255,255,.14);
+ }
+
+ .quota-time {
+ padding: 5px 10px;
+ border-radius: 999px;
+ background: rgba(255,255,255,.075);
+ color: var(--quota-muted);
+ font-size: clamp(10px, 1.35vw, 12px);
+ white-space: nowrap;
+ }
+
+ .quota-toolbox {
+ justify-self: end;
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid rgba(255,255,255,.12);
+ border-radius: 999px;
+ background: rgba(255,255,255,.06);
+ }
+
+ .quota-toolbox button {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ min-height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: rgba(239,247,241,.82);
+ font-size: 14px;
+ }
+
+ body:not(.eink-client) .quota-toolbox button {
+ width: auto;
+ min-width: 28px;
+ padding: 0 8px;
+ font-size: 11px;
+ font-weight: 700;
+ white-space: nowrap;
+ }
+
+ .quota-toolbox button:hover {
+ background: rgba(255,255,255,.1);
+ }
+
+ .quota-toolbox button:disabled {
+ cursor: wait;
+ opacity: .48;
+ }
+
+ .quota-action-status {
+ min-height: 17px;
+ color: var(--quota-muted);
+ font-size: clamp(9px, 1.2vw, 11px);
+ line-height: 1.35;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .quota-action-status.working {
+ color: var(--quota-blue);
+ }
+
+ .quota-action-status.success {
+ color: var(--quota-accent);
+ }
+
+ .quota-action-status.error {
+ color: #ff8a7a;
+ }
+
+ .quota-local-strip {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ min-height: 0;
+ }
+
+ .quota-local-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 5px 8px;
+ min-width: 0;
+ padding: 8px 10px;
+ border: 1px solid rgba(255,255,255,.1);
+ background: rgba(255,255,255,.045);
+ color: rgba(239,247,241,.78);
+ }
+
+ .quota-local-card strong,
+ .quota-local-card span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+ }
+
+ .quota-local-card b {
+ color: var(--quota-blue);
+ font-size: 12px;
+ white-space: nowrap;
+ }
+
+ body.viewer-fullscreen.mode-sideboard .sideboard-view.active,
+ body.dashboard-viewer.mode-sideboard .sideboard-view.active,
+ body.deck-window.mode-sideboard .sideboard-view.active {
+ width: 100%;
+ min-height: var(--viewer-height);
+ align-self: stretch;
+ }
+
+ body.viewer-fullscreen.mode-quota .quota-view.active,
+ body.dashboard-viewer.mode-quota .quota-view.active,
+ body.deck-window.mode-quota .quota-view.active {
+ width: 100%;
+ min-height: var(--viewer-height);
+ align-self: stretch;
+ }
+
+ body.viewer-fullscreen.mode-quota .quota-shell,
+ body.dashboard-viewer.mode-quota .quota-shell,
+ body.deck-window.mode-quota .quota-shell {
+ min-height: var(--viewer-height);
+ height: auto;
+ border: 0;
+ border-radius: 0;
+ padding: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ }
+
+ body.deck-window.mode-quota .quota-shell {
+ grid-template-rows: auto minmax(0, 1fr);
+ height: 100vh;
+ min-height: 0;
+ }
+
+ body.deck-window.mode-quota .quota-grid,
+ body.deck-window.mode-quota .quota-account-page {
+ height: 100%;
+ min-height: 0;
+ align-content: stretch;
+ }
+
+ body.viewer-fullscreen.mode-quota .quota-account-card,
+ body.dashboard-viewer.mode-quota .quota-account-card,
+ body.deck-window.mode-quota .quota-account-card {
+ gap: 8px;
+ padding: 12px 14px;
+ }
+
+ body.deck-window.mode-quota .quota-account-card {
+ height: 100%;
+ grid-template-rows: auto minmax(0, 1fr) auto auto auto;
+ }
+
+ body.deck-window.mode-quota .quota-pair {
+ align-items: stretch;
+ }
+
+ body.deck-window.mode-quota .quota-pair.single {
+ width: min(100%, 760px);
+ align-self: center;
+ }
+
+ body.viewer-fullscreen.mode-quota .quota-provider,
+ body.dashboard-viewer.mode-quota .quota-provider,
+ body.deck-window.mode-quota .quota-provider {
+ gap: 7px;
+ }
+
+ body.deck-window.mode-quota .quota-provider {
+ align-content: center;
+ }
+
+ body.viewer-fullscreen.mode-quota .quota-window,
+ body.dashboard-viewer.mode-quota .quota-window {
+ gap: 3px 8px;
+ }
+
+ body.deck-window.mode-quota .quota-title strong,
+ body.deck-window.mode-quota .quota-window b {
+ line-height: 1.08;
+ }
+
+ body.viewer-fullscreen.mode-quota .quota-footer,
+ body.dashboard-viewer.mode-quota .quota-footer {
+ padding-top: 8px;
+ }
+
+ body.deck-window.mode-quota .quota-footer {
+ grid-template-columns: auto auto minmax(0, 1fr);
+ justify-items: start;
+ }
+
+ body.deck-window.mode-quota .quota-toolbox {
+ justify-self: start;
+ }
+
+ body.viewport-portrait .quota-view {
+ width: 100%;
+ justify-self: stretch;
+ align-self: start;
+ }
+
+ body.viewport-portrait .quota-shell {
+ grid-template-rows: auto auto;
+ align-content: start;
+ min-height: auto;
+ height: auto;
+ overflow: visible;
+ padding: 12px 12px calc(18px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) 12px;
+ }
+
+ body.viewport-portrait .quota-top {
+ grid-template-columns: 1fr;
+ align-items: start;
+ }
+
+ body.viewport-portrait .quota-status {
+ justify-self: start;
+ }
+
+ body.viewport-portrait .quota-title {
+ flex-wrap: wrap;
+ white-space: normal;
+ align-items: center;
+ }
+
+ body.viewport-portrait .quota-title strong {
+ font-size: clamp(18px, 6.5vw, 30px);
+ }
+
+ body.viewport-portrait .quota-title span,
+ body.viewport-portrait .quota-status {
+ font-size: clamp(11px, 3.3vw, 14px);
+ }
+
+ body.viewport-portrait .quota-tabs {
+ gap: 6px;
+ }
+
+ body.viewport-portrait .quota-tab {
+ min-height: 32px;
+ padding: 7px 12px;
+ }
+
+ body.viewport-portrait .quota-grid {
+ overflow: visible;
+ }
+
+ body.viewport-portrait .quota-account-card {
+ gap: 14px;
+ overflow: visible;
+ }
+
+ body.viewport-portrait .quota-account-head {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ align-items: start;
+ }
+
+ body.viewport-portrait .quota-check {
+ grid-row: 1;
+ }
+
+ body.viewport-portrait .quota-account-email {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ line-height: 1.2;
+ font-size: clamp(15px, 4.8vw, 22px);
+ }
+
+ body.viewport-portrait .quota-account-switcher {
+ grid-column: 2 / -1;
+ grid-row: 2;
+ justify-self: start;
+ }
+
+ body.viewport-portrait .quota-pill {
+ grid-column: 3;
+ grid-row: 1;
+ justify-self: end;
+ }
+
+ body.viewport-portrait .quota-pair {
+ grid-template-columns: 1fr;
+ gap: 16px;
+ }
+
+ body.viewport-portrait .quota-provider {
+ gap: 12px;
+ }
+
+ body.viewport-portrait .quota-provider-title {
+ font-size: clamp(14px, 4.2vw, 18px);
+ }
+
+ body.viewport-portrait .quota-window {
+ gap: 6px 8px;
+ }
+
+ body.viewport-portrait .quota-window span {
+ font-size: clamp(11px, 3.4vw, 14px);
+ }
+
+ body.viewport-portrait .quota-window b {
+ font-size: clamp(16px, 5vw, 22px);
+ }
+
+ body.viewport-portrait .quota-window small {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ line-height: 1.2;
+ }
+
+ body.viewport-portrait .quota-credit-row {
+ flex-wrap: wrap;
+ line-height: 1.25;
+ }
+
+ body.viewport-portrait .quota-footer {
+ grid-template-columns: 1fr;
+ justify-items: start;
+ gap: 8px;
+ }
+
+ body.viewport-portrait .quota-toolbox {
+ justify-self: start;
+ flex-wrap: wrap;
+ }
+
+ body.viewport-portrait .quota-action-status {
+ white-space: normal;
+ }
+
+ body.viewer-fullscreen.mode-quota.viewport-portrait main,
+ body.dashboard-viewer.mode-quota.viewport-portrait main {
+ overflow-y: auto;
+ }
+
+ body.viewer-fullscreen.mode-quota.viewport-portrait .quota-view.active,
+ body.dashboard-viewer.mode-quota.viewport-portrait .quota-view.active {
+ width: 100%;
+ height: auto;
+ min-height: var(--viewer-height);
+ align-self: start;
+ align-content: start;
+ overflow-y: auto;
+ }
+
+ body.viewer-fullscreen.mode-quota.viewport-portrait .quota-shell,
+ body.dashboard-viewer.mode-quota.viewport-portrait .quota-shell {
+ align-content: start;
+ min-height: var(--viewer-height);
+ height: auto;
+ overflow: visible;
+ padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(18px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ }
+
+ .sideboard-shell {
+ --side-accent: #f2b45f;
+ --side-accent-2: #7ed9e8;
+ --side-good: #91dd98;
+ --side-warn: #ffd27a;
+ --side-bg: #071015;
+ --side-panel: rgba(17, 25, 30, 0.88);
+ --side-line: rgba(255, 255, 255, 0.16);
+ --skin-image: url("/sideboard-skins/command.png");
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(190px, 1.05fr) minmax(260px, 1.38fr) minmax(190px, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 8px;
+ width: 100%;
+ min-height: 360px;
+ padding: 12px;
+ overflow: hidden;
+ color: #f7f2e8;
+ background: var(--side-bg);
+ border: 1px solid rgba(255,255,255,.18);
+ border-radius: 8px;
+ }
+
+ .sideboard-shell::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background-image: var(--skin-image);
+ background-position: center;
+ background-size: cover;
+ opacity: .68;
+ transform: scale(1.015);
+ }
+
+ .sideboard-shell::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background:
+ linear-gradient(90deg, rgba(3,6,8,.76), rgba(3,6,8,.48) 44%, rgba(3,6,8,.74)),
+ radial-gradient(circle at 50% 42%, rgba(0,0,0,.08), rgba(0,0,0,.58) 72%);
+ }
+
+ .sideboard-shell > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ body.viewer-fullscreen.mode-sideboard .sideboard-shell,
+ body.dashboard-viewer.mode-sideboard .sideboard-shell,
+ body.deck-window.mode-sideboard .sideboard-shell {
+ min-height: var(--viewer-height);
+ height: auto;
+ border: 0;
+ border-radius: 0;
+ padding: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ }
+
+ body.eink-client.viewer-fullscreen.mode-sideboard .sideboard-shell,
+ body.eink-client.dashboard-viewer.mode-sideboard .sideboard-shell,
+ body.eink-client.viewer-immersive.mode-sideboard .sideboard-shell {
+ min-height: 0;
+ height: auto;
+ overflow: visible;
+ }
+
+ body.deck-window.mode-sideboard .sideboard-shell {
+ height: 100vh;
+ min-height: 0;
+ }
+
+ body.deck-window.mode-sideboard .sideboard-shell::before {
+ transform: none;
+ }
+
+ body.deck-window.mode-sideboard .side-title strong,
+ body.deck-window.mode-sideboard .metric-value {
+ line-height: 1.08;
+ }
+
+ body.deck-window.mode-sideboard .side-big {
+ line-height: 1;
+ }
+
+ .sideboard-shell.skin-dial {
+ --side-accent: #7ed9e8;
+ --side-accent-2: #f2b45f;
+ --side-bg: #061116;
+ --skin-image: url("/sideboard-skins/dial.png");
+ }
+
+ .sideboard-shell.skin-focus {
+ --side-accent: #91dd98;
+ --side-accent-2: #e0d5aa;
+ --side-bg: #0c130e;
+ --side-panel: rgba(25, 36, 25, 0.88);
+ --skin-image: url("/sideboard-skins/focus.png");
+ }
+
+ .sideboard-top {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ align-items: center;
+ min-width: 0;
+ }
+
+ .side-title {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+ white-space: nowrap;
+ }
+
+ .side-title strong {
+ font-size: clamp(16px, 3.4vw, 28px);
+ line-height: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .side-title span,
+ .side-updated {
+ color: rgba(247,242,232,.68);
+ font-size: clamp(10px, 1.5vw, 13px);
+ }
+
+ .skin-picker {
+ display: flex;
+ gap: 5px;
+ }
+
+ .skin-picker button {
+ min-width: 34px;
+ min-height: 30px;
+ padding: 4px 8px;
+ border-radius: 4px;
+ background: rgba(255,255,255,.08);
+ color: rgba(247,242,232,.78);
+ }
+
+ .skin-picker button.active {
+ background: var(--side-accent);
+ color: #071015;
+ border-color: var(--side-accent);
+ }
+
+ .side-panel {
+ display: grid;
+ min-width: 0;
+ min-height: 0;
+ padding: 8px;
+ border: 1px solid var(--side-line);
+ background: var(--side-panel);
+ overflow: hidden;
+ }
+
+ .side-load {
+ align-content: center;
+ gap: 8px;
+ border-right: 1px solid rgba(255,255,255,.22);
+ }
+
+ .side-kicker {
+ color: rgba(247,242,232,.68);
+ font-size: clamp(9px, 1.2vw, 11px);
+ line-height: 1;
+ text-transform: uppercase;
+ }
+
+ .side-big {
+ color: var(--side-accent);
+ font-size: clamp(54px, 10.4vw, 84px);
+ font-weight: 800;
+ line-height: .88;
+ letter-spacing: 0;
+ }
+
+ .side-error {
+ color: var(--side-warn);
+ font-size: clamp(10px, 1.5vw, 13px);
+ line-height: 1.25;
+ }
+
+ .side-summary {
+ display: grid;
+ gap: 4px;
+ color: rgba(247,242,232,.78);
+ font-size: clamp(10px, 1.5vw, 13px);
+ line-height: 1.25;
+ }
+
+ .metric-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ }
+
+ .metric-card {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto 1fr auto;
+ gap: 3px 6px;
+ min-width: 0;
+ min-height: 0;
+ padding: 8px;
+ border: 1px solid rgba(255,255,255,.09);
+ background: rgba(255,255,255,.03);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ border-radius: 8px;
+ transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s ease, border-color 0.2s ease;
+ }
+
+ .metric-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, .32);
+ border-color: rgba(143, 209, 255, .22);
+ }
+
+ .metric-card .side-kicker {
+ grid-column: 1;
+ }
+
+ .metric-value {
+ grid-column: 2;
+ grid-row: 1 / 3;
+ align-self: center;
+ color: #fff;
+ font-size: clamp(21px, 3.7vw, 32px);
+ font-weight: 800;
+ line-height: 1;
+ white-space: nowrap;
+ font-family: "Share Tech Mono", monospace;
+ }
+
+ .metric-sub {
+ grid-column: 1 / -1;
+ color: rgba(247,242,232,.68);
+ font-size: clamp(9px, 1.35vw, 12px);
+ line-height: 1.2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .mini-bar {
+ grid-column: 1 / -1;
+ height: 4px;
+ background: rgba(255,255,255,.1);
+ border-radius: 999px;
+ overflow: hidden;
+ }
+
+ .mini-bar span {
+ display: block;
+ width: 0%;
+ height: 100%;
+ background: var(--grad-accent);
+ border-radius: 999px;
+ transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
+ }
+
+ .side-feed {
+ grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
+ gap: 6px;
+ }
+
+ .feed-list {
+ display: grid;
+ gap: 4px;
+ min-height: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ overflow: hidden;
+ color: rgba(247,242,232,.82);
+ font-size: clamp(10px, 1.45vw, 13px);
+ line-height: 1.25;
+ }
+
+ .feed-list li {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .process-list li {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 6px;
+ }
+
+ /* Windows console: settings are a dedicated product page. Everyday views
+ keep a compact header and own the entire remaining viewport. */
+ body.pc-console {
+ height: 100dvh;
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ body.pc-console:not(.mode-setup) header .setup-panels,
+ body.pc-console:not(.mode-setup) header .device-management-panel,
+ body.pc-console:not(.mode-setup) header .new-device-panel,
+ body.pc-console:not(.mode-setup) header .diagnostics-panel,
+ body.pc-console:not(.mode-setup) header .driver-state {
+ display: none !important;
+ }
+
+ body.pc-console.mode-setup {
+ grid-template-rows: minmax(0, 1fr);
+ }
+
+ body.pc-console.mode-setup header {
+ align-content: start;
+ grid-auto-rows: max-content;
+ overflow-x: hidden;
+ overflow-y: auto;
+ }
+
+ body.pc-console.mode-setup main {
+ display: none;
+ }
+
+ body.pc-console:not(.mode-setup) main {
+ min-height: 0;
+ overflow: hidden;
+ }
+
+ body.pc-console:not(.mode-setup) .app-view.active {
+ width: 100%;
+ max-width: none;
+ height: 100%;
+ min-height: 0;
+ align-self: stretch;
+ justify-self: stretch;
+ }
+
+ body.pc-console.mode-display .screen-media {
+ width: 100%;
+ max-width: none;
+ height: 100%;
+ max-height: 100%;
+ aspect-ratio: auto;
+ object-fit: contain;
+ }
+
+ body.pc-console.mode-sideboard .sideboard-shell,
+ body.pc-console.mode-quota .quota-shell {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ }
+
+ body.pc-console.mode-quota .quota-shell {
+ overflow: hidden;
+ }
+
+ .process-list li > span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .process-list li > b {
+ white-space: nowrap;
+ }
+
+ .feed-heading {
+ color: var(--side-accent-2);
+ font-size: clamp(10px, 1.4vw, 12px);
+ text-transform: uppercase;
+ }
+
+ .sideboard-subpage {
+ grid-column: 1 / -1;
+ min-width: 0;
+ }
+
+ #systemSideboardPage {
+ display: contents;
+ }
+
+ /* display:contents means this element has no box of its own, so the
+ .sideboard-shell > * { z-index:1 } rule doesn't lift it above the
+ shell's dark ::after overlay. Lift its children explicitly. */
+ #systemSideboardPage > * {
+ position: relative;
+ z-index: 1;
+ }
+
+ /* 子頁 hidden;面板開合由 JS inline display !important 負責 */
+ #systemSideboardPage[hidden],
+ #customSideboardPage[hidden],
+ .custom-cards-toolbar button[hidden],
+ .custom-manager-header button[hidden] {
+ display: none !important;
+ }
+
+ .sideboard-page-tabs {
+ grid-column: 1 / -1;
+ display: flex;
+ gap: 5px;
+ min-width: 0;
+ }
+
+ .sideboard-page-tabs button {
+ min-height: 28px;
+ padding: 4px 12px;
+ border-radius: 999px;
+ border-color: rgba(255,255,255,.14);
+ background: rgba(255,255,255,.06);
+ color: rgba(247,242,232,.72);
+ font-size: 11px;
+ }
+
+ .sideboard-page-tabs button.active {
+ border-color: var(--side-accent);
+ background: var(--side-accent);
+ color: #071015;
+ }
+
+ .custom-sideboard-page {
+ display: grid;
+ grid-template-rows: auto auto auto minmax(0, 1fr) auto;
+ gap: 8px;
+ min-height: 0;
+ min-width: 0;
+ overflow: auto;
+ }
+
+ .windows-notification-control {
+ grid-template-columns: minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 3px 10px;
+ padding: 8px 10px;
+ border: 1px solid rgba(255,255,255,.12);
+ border-radius: 8px;
+ background: rgba(255,255,255,.04);
+ }
+
+ .windows-notification-control > div:first-child {
+ display: grid;
+ gap: 3px;
+ }
+
+ .windows-notification-control strong {
+ color: var(--side-accent);
+ font-size: 13px;
+ }
+
+ .windows-notification-control span,
+ .windows-notification-control p {
+ color: rgba(247,242,232,.66);
+ font-size: 11px;
+ }
+
+ .windows-notification-control p {
+ grid-column: 1 / 2;
+ margin: 0;
+ }
+
+ .windows-notification-control .custom-cards-toolbar {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ justify-content: flex-end;
+ }
+
+ .custom-card-settings {
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid var(--side-line);
+ border-radius: 8px;
+ background: rgba(17,25,30,.92);
+ }
+
+ .custom-settings-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+ }
+
+ .custom-settings-header > div {
+ display: grid;
+ gap: 3px;
+ }
+
+ .custom-settings-header strong {
+ color: var(--side-accent);
+ font-size: 14px;
+ }
+
+ .custom-settings-header span,
+ .custom-settings-help {
+ color: rgba(247,242,232,.62);
+ font-size: 11px;
+ line-height: 1.35;
+ }
+
+ .custom-card-settings-form {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ align-items: end;
+ gap: 8px;
+ }
+
+ .custom-card-settings-form label {
+ display: grid;
+ gap: 4px;
+ color: rgba(247,242,232,.72);
+ font-size: 11px;
+ }
+
+ .custom-card-settings-form select,
+ .custom-card-settings-form input[type="number"] {
+ width: 100%;
+ min-height: 30px;
+ padding: 5px 7px;
+ border: 1px solid rgba(255,255,255,.16);
+ border-radius: 5px;
+ color: #fff;
+ background: rgba(255,255,255,.07);
+ }
+
+ .custom-card-settings-form .custom-settings-check {
+ display: flex;
+ align-items: center;
+ min-height: 30px;
+ gap: 6px;
+ }
+
+ .custom-settings-check input {
+ accent-color: var(--side-accent);
+ }
+
+ .custom-card-settings-form > .custom-settings-help {
+ grid-column: 1 / -1;
+ margin: 0;
+ }
+
+ .custom-card-settings-form .custom-source-form-actions {
+ grid-column: 1 / -1;
+ }
+
+ .custom-cards-top,
+ .custom-manager-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-width: 0;
+ }
+
+ .custom-cards-title,
+ .custom-manager-header > div {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ }
+
+ .custom-cards-title strong,
+ .custom-manager-header strong {
+ color: var(--side-accent);
+ font-size: clamp(16px, 3vw, 24px);
+ }
+
+ .custom-cards-title span,
+ .custom-manager-header span,
+ .custom-source-meta,
+ .custom-source-health,
+ .custom-source-form label,
+ .custom-credential-panel > span {
+ color: rgba(247,242,232,.66);
+ font-size: 11px;
+ }
+
+ .custom-cards-toolbar,
+ .custom-source-actions,
+ .custom-source-form-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ align-items: center;
+ }
+
+ .custom-cards-toolbar button,
+ .custom-manager-header button,
+ .custom-source-actions button,
+ .custom-source-form-actions button {
+ min-height: 28px;
+ padding: 4px 9px;
+ border-color: rgba(255,255,255,.16);
+ background: rgba(255,255,255,.08);
+ color: rgba(247,242,232,.86);
+ font-size: 11px;
+ }
+
+ .custom-cards-toolbar button:hover,
+ .custom-manager-header button:hover,
+ .custom-source-actions button:hover,
+ .custom-source-form-actions button:hover {
+ border-color: var(--side-accent);
+ }
+
+ .custom-cards-status {
+ min-height: 1em;
+ }
+
+ .windows-notification-ok { color: var(--side-good); }
+ .windows-notification-warn { color: var(--side-warn); }
+
+ .custom-cards-status.ok,
+ .custom-source-health.health-active {
+ color: var(--side-good);
+ }
+
+ .custom-cards-status.error,
+ .custom-source-health.health-disabled,
+ .custom-source-health.health-stale {
+ color: var(--side-warn);
+ }
+
+ .custom-cards-status.muted,
+ .custom-source-health.health-waiting {
+ color: rgba(247,242,232,.58);
+ }
+
+ .custom-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ align-content: start;
+ gap: 8px;
+ min-width: 0;
+ min-height: 0;
+ overflow: auto;
+ padding: 1px;
+ }
+
+ .custom-card,
+ .custom-empty-state,
+ .custom-sources-manager,
+ .custom-credential-panel {
+ min-width: 0;
+ border: 1px solid var(--side-line);
+ background: rgba(17,25,30,.9);
+ border-radius: 8px;
+ }
+
+ .custom-card {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: 8px;
+ min-height: 142px;
+ padding: 10px;
+ overflow: hidden;
+ }
+
+ .custom-card-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 8px;
+ min-width: 0;
+ }
+
+ .custom-card-title-block {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ }
+
+ .custom-card-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #fff;
+ font-size: 15px;
+ }
+
+ .custom-card-source,
+ .custom-card-footer,
+ .custom-card-freshness {
+ color: rgba(247,242,232,.58);
+ font-size: 10px;
+ }
+
+ .custom-card-freshness {
+ flex: 0 0 auto;
+ padding: 2px 5px;
+ border: 1px solid rgba(255,255,255,.14);
+ border-radius: 999px;
+ }
+
+ .custom-card-freshness.freshness-fresh {
+ color: var(--side-good);
+ }
+
+ .custom-card-freshness.freshness-stale {
+ color: var(--side-warn);
+ }
+
+ .custom-status-content,
+ .custom-metric-content {
+ display: grid;
+ align-content: center;
+ gap: 6px;
+ min-height: 0;
+ }
+
+ .custom-status-value,
+ .custom-metric-value {
+ color: var(--side-accent);
+ font-size: clamp(22px, 4vw, 34px);
+ line-height: 1.05;
+ overflow-wrap: anywhere;
+ }
+
+ .custom-metric-unit {
+ margin-left: 4px;
+ color: rgba(247,242,232,.72);
+ font-size: .5em;
+ }
+
+ .custom-status-detail,
+ .custom-card-empty {
+ color: rgba(247,242,232,.72);
+ font-size: 12px;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+ }
+
+ .custom-progress {
+ height: 6px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgba(255,255,255,.1);
+ }
+
+ .custom-progress span {
+ display: block;
+ height: 100%;
+ border-radius: inherit;
+ background: var(--side-accent);
+ }
+
+ .custom-feed-list {
+ display: grid;
+ align-content: start;
+ gap: 6px;
+ min-height: 0;
+ margin: 0;
+ padding: 0;
+ overflow: auto;
+ list-style: none;
+ }
+
+ .custom-feed-item {
+ min-width: 0;
+ padding: 6px 7px;
+ border-left: 3px solid var(--side-accent-2);
+ background: rgba(255,255,255,.045);
+ }
+
+ .custom-feed-item.severity-success { border-left-color: var(--side-good); }
+ .custom-feed-item.severity-warning { border-left-color: var(--side-warn); }
+ .custom-feed-item.severity-error { border-left-color: #ff8d8d; }
+
+ .custom-feed-meta {
+ display: flex;
+ justify-content: space-between;
+ gap: 6px;
+ color: rgba(247,242,232,.58);
+ font-size: 10px;
+ }
+
+ .custom-feed-text {
+ margin-top: 3px;
+ color: rgba(247,242,232,.9);
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+ }
+
+ .custom-feed-text.is-streaming::after {
+ content: "▋";
+ display: inline-block;
+ margin-left: 2px;
+ color: var(--side-accent);
+ animation: custom-stream-caret 650ms steps(1, end) infinite;
+ }
+
+ @keyframes custom-stream-caret {
+ 50% { opacity: 0; }
+ }
+
+ .custom-key-value-list {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr);
+ align-content: start;
+ gap: 5px 8px;
+ margin: 0;
+ overflow: auto;
+ }
+
+ .custom-key-value-list dt,
+ .custom-key-value-list dd {
+ min-width: 0;
+ margin: 0;
+ overflow-wrap: anywhere;
+ }
+
+ .custom-key-value-list dt { color: rgba(247,242,232,.58); }
+ .custom-key-value-list dd { color: rgba(247,242,232,.92); }
+
+ .custom-card-footer {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .custom-empty-state {
+ grid-column: 1 / -1;
+ padding: 24px 16px;
+ color: rgba(247,242,232,.7);
+ text-align: center;
+ }
+
+ .custom-sources-manager {
+ gap: 8px;
+ padding: 10px;
+ max-height: min(46vh, 430px);
+ overflow: auto;
+ }
+
+ .custom-manager-header .custom-manager-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ .custom-source-list {
+ display: grid;
+ gap: 5px;
+ }
+
+ .custom-source-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-width: 0;
+ padding: 7px;
+ border: 1px solid rgba(255,255,255,.1);
+ background: rgba(255,255,255,.035);
+ }
+
+ .custom-source-summary {
+ display: grid;
+ gap: 2px;
+ min-width: 0;
+ }
+
+ .custom-source-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ color: #fff;
+ font-size: 12px;
+ }
+
+ .custom-source-actions {
+ justify-content: flex-end;
+ flex: 0 0 auto;
+ }
+
+ .custom-source-action {
+ padding: 3px 6px !important;
+ font-size: 10px !important;
+ }
+
+ .custom-source-form,
+ .custom-credential-panel {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 7px;
+ padding: 10px;
+ }
+
+ .custom-source-form > strong,
+ .custom-credential-panel > strong,
+ .custom-credential-panel > span,
+ .custom-credential-panel > textarea,
+ .custom-source-form-actions {
+ grid-column: 1 / -1;
+ }
+
+ .custom-source-form label {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ }
+
+ .custom-source-form input,
+ .custom-source-form select,
+ .custom-credential-panel textarea {
+ width: 100%;
+ min-width: 0;
+ padding: 6px 7px;
+ background: rgba(0,0,0,.22);
+ font-size: 12px;
+ }
+
+ .custom-credential-panel textarea {
+ resize: vertical;
+ color: rgba(247,242,232,.9);
+ font-family: "Cascadia Mono", Consolas, monospace;
+ line-height: 1.35;
+ }
+
+ .custom-manager-empty {
+ margin: 0;
+ color: rgba(247,242,232,.58);
+ font-size: 11px;
+ }
+
+ /* Phone client: content-first. PC console keeps full connect/pairing chrome. */
+ body.phone-client header {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(12px + var(--safe-area-inset-right, env(safe-area-inset-right))) 10px calc(12px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ }
+
+ body.phone-client .brand-copy span {
+ display: none;
+ }
+
+ body.phone-client .new-device-panel,
+ body.phone-client .device-management-panel,
+ body.phone-client .diagnostics-panel,
+ body.phone-client.device-trusted .connect-panel,
+ body.phone-client.device-trusted .driver-state {
+ display: none !important;
+ }
+
+ body.phone-client.device-trusted:not(.mode-display) .utility-controls,
+ body.phone-client.device-trusted:not(.mode-display) .setup-panels {
+ display: none !important;
+ }
+
+ body.phone-client.device-trusted.mode-display #displaySettingsToggle {
+ display: inline-flex;
+ }
+
+ body.phone-client.device-trusted .setup-panels {
+ display: none;
+ }
+
+ body.phone-client.device-trusted.mode-display.display-settings-open .setup-panels {
+ display: flex;
+ }
+
+ /* Unpaired phone: keep a short trust note, hide PC pairing QR / guide. */
+ body.phone-client:not(.device-trusted) .connect-panel {
+ grid-template-columns: 1fr;
+ padding: 12px;
+ gap: 10px;
+ }
+
+ body.phone-client:not(.device-trusted) .connect-qr,
+ body.phone-client:not(.device-trusted) .pairing-guide,
+ body.phone-client:not(.device-trusted) .connect-title,
+ body.phone-client:not(.device-trusted) .connect-links,
+ body.phone-client:not(.device-trusted) .trusted-devices,
+ body.phone-client:not(.device-trusted) .connect-actions #launchDeckWindow {
+ display: none !important;
+ }
+
+ body.phone-client:not(.device-trusted) .connect-actions {
+ grid-template-columns: 1fr;
+ }
+
+ body.phone-client:not(.device-trusted):not(.pc-console) header,
+ body.phone-client:not(.device-trusted):not(.pc-console) .display-view,
+ body.phone-client:not(.device-trusted):not(.pc-console) .sideboard-view,
+ body.phone-client:not(.device-trusted):not(.pc-console) .quota-view {
+ display: none !important;
+ }
+
+ body.phone-client:not(.device-trusted):not(.pc-console) main {
+ display: grid;
+ place-items: start center;
+ padding-top: max(28px, var(--safe-area-inset-top, env(safe-area-inset-top)));
+ }
+
+ body.phone-client .panel {
+ order: 5;
+ }
+
+ body.phone-client main {
+ padding: 10px;
+ padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right)));
+ padding-bottom: calc(10px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
+ }
+
+ body.phone-client.mode-display .screen-media {
+ width: 100%;
+ max-width: none;
+ border-radius: 4px;
+ }
+
+ /*
+ * Force landscape presentation on phone clients held upright.
+ * Physical portrait viewport is rotated so the app always lays out as landscape.
+ */
+ html.phone-force-landscape {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ background: #0d131a;
+ }
+
+ html.phone-force-landscape body.force-landscape {
+ position: fixed;
+ top: 0;
+ left: 100%;
+ width: 100dvh;
+ height: 100dvw;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ transform: rotate(90deg);
+ transform-origin: top left;
+ overflow-x: hidden;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ grid-template-rows: auto 1fr;
+ }
+
+ html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display {
+ width: 100dvh;
+ height: 100dvw;
+ overflow: hidden;
+ grid-template-rows: 1fr;
+ }
+
+ html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display main {
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ overflow: hidden;
+ }
+
+ /* Logical landscape box after CSS rotate — same geometry iOS uses via viewer vars. */
+ html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display .screen-media {
+ position: fixed;
+ left: 50%;
+ top: 50%;
+ width: var(--viewer-width);
+ height: var(--viewer-height);
+ max-width: none;
+ max-height: none;
+ aspect-ratio: auto;
+ object-fit: contain;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ transform: translate(-50%, -50%);
+ }
+
+ html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display .display-view.active {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ place-items: center;
+ }
+
+ html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display .display-toolbar {
+ position: fixed;
+ top: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
+ left: 50%;
+ transform: translateX(-50%);
+ }
+
+ /*
+ * Logical landscape chrome (CSS force-rotate OR real landscape).
+ * The generic @media (max-width: 960px) portrait compaction sets
+ * utility buttons to width:100%, which turns Fullscreen into a full bar
+ * once the rotated logical width exceeds a phone portrait width.
+ */
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted header,
+ body.phone-client.device-trusted.viewport-landscape header {
+ gap: 6px;
+ padding: 6px 10px;
+ padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left, 0px)));
+ padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .brand,
+ body.phone-client.device-trusted.viewport-landscape .brand {
+ display: none;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .top-controls,
+ body.phone-client.device-trusted.viewport-landscape .top-controls {
+ display: grid !important;
+ grid-template-columns: minmax(0, 1fr) auto !important;
+ gap: 8px !important;
+ width: 100% !important;
+ align-items: center;
+ justify-content: stretch;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .view-switcher,
+ body.phone-client.device-trusted.viewport-landscape .view-switcher {
+ display: flex !important;
+ flex-wrap: nowrap !important;
+ width: auto !important;
+ max-width: 100%;
+ min-width: 0;
+ justify-self: start;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .view-switcher .mode-button,
+ body.phone-client.device-trusted.viewport-landscape .view-switcher .mode-button {
+ width: auto !important;
+ flex: 0 1 auto;
+ min-width: 0;
+ padding-inline: 12px;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls,
+ body.phone-client.device-trusted.viewport-landscape .utility-controls {
+ display: flex !important;
+ flex-wrap: nowrap !important;
+ width: auto !important;
+ max-width: 100%;
+ justify-content: flex-end !important;
+ align-items: center;
+ gap: 6px !important;
+ justify-self: end;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls > *,
+ body.phone-client.device-trusted.viewport-landscape .utility-controls > * {
+ width: auto !important;
+ max-width: none;
+ flex: 0 0 auto;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls select,
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls button,
+ body.phone-client.device-trusted.viewport-landscape .utility-controls select,
+ body.phone-client.device-trusted.viewport-landscape .utility-controls button {
+ width: auto !important;
+ min-width: 0;
+ min-height: 36px;
+ padding-inline: 10px;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls #fullscreen,
+ body.phone-client.device-trusted.viewport-landscape .utility-controls #fullscreen {
+ display: inline-flex !important;
+ width: auto !important;
+ max-width: 140px;
+ white-space: nowrap;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls .locale-control,
+ body.phone-client.device-trusted.viewport-landscape .utility-controls .locale-control {
+ display: inline-flex !important;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted main,
+ body.phone-client.device-trusted.viewport-landscape main {
+ width: 100%;
+ min-height: 0;
+ padding: 8px 10px;
+ padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left, 0px)));
+ padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
+ padding-bottom: calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted .app-view.active,
+ body.phone-client.device-trusted.viewport-landscape .app-view.active {
+ width: 100%;
+ max-width: none;
+ justify-self: stretch;
+ min-height: 0;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted.mode-sideboard .sideboard-shell,
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted.mode-quota .quota-shell,
+ body.phone-client.device-trusted.viewport-landscape.mode-sideboard .sideboard-shell,
+ body.phone-client.device-trusted.viewport-landscape.mode-quota .quota-shell {
+ width: 100%;
+ max-width: none;
+ min-height: 0;
+ }
+
+ html.phone-force-landscape body.force-landscape.phone-client.device-trusted.mode-sideboard .mobile-overview,
+ body.phone-client.device-trusted.viewport-landscape.mode-sideboard .mobile-overview {
+ width: 100%;
+ max-width: none;
+ }
+
+ /* Portrait phone: content scrolls under a short chrome bar. */
+ body.phone-client.viewport-portrait {
+ height: 100dvh;
+ max-height: 100dvh;
+ overflow: hidden;
+ }
+
+ body.phone-client.viewport-portrait header {
+ gap: 8px;
+ padding: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) 8px calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ }
+
+ body.phone-client.viewport-portrait .brand {
+ display: none;
+ }
+
+ body.phone-client.viewport-portrait .panel:not(.setup-panels .panel) {
+ display: none;
+ }
+
+ body.phone-client.viewport-portrait .setup-panels {
+ justify-content: center;
+ }
+
+ body.phone-client.viewport-portrait .top-controls {
+ gap: 8px;
+ }
+
+ body.phone-client.viewport-portrait .view-switcher {
+ width: 100%;
+ }
+
+ body.phone-client.viewport-portrait .view-switcher .mode-button {
+ min-height: 40px;
+ font-size: 14px;
+ font-weight: 700;
+ }
+
+ body.phone-client.viewport-portrait .utility-controls {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 6px;
+ }
+
+ body.phone-client.viewport-portrait .utility-controls button {
+ min-width: 0;
+ padding-inline: 6px;
+ font-size: 12px;
+ }
+
+ body.phone-client.viewport-portrait #rotation,
+ body.phone-client.viewport-portrait:not(.eink-client) #fullscreen {
+ display: none;
+ }
+
+ body.phone-client.viewport-portrait .utility-controls .status {
+ grid-column: 1 / -1;
+ min-height: 32px;
+ font-size: 12px;
+ }
+
+ body.phone-client.viewport-portrait.display-unavailable .utility-controls .status {
+ display: none;
+ }
+
+ body.phone-client.viewport-portrait main {
+ min-height: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ -webkit-overflow-scrolling: touch;
+ padding: 8px;
+ padding-left: calc(8px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ padding-right: calc(8px + var(--safe-area-inset-right, env(safe-area-inset-right)));
+ padding-bottom: calc(12px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
+ align-content: start;
+ }
+
+ /* Display: keep 16:9 readable in portrait instead of a tiny strip. */
+ body.phone-client.viewport-portrait.mode-display .display-view.active {
+ width: 100%;
+ align-content: start;
+ justify-items: stretch;
+ gap: 8px;
+ }
+
+ body.phone-client.viewport-portrait.mode-display .display-view.active::after {
+ content: var(--i18n-portrait-hint, "直式適合看資訊板 / 額度;副螢幕建議橫向全螢幕。");
+ color: var(--quiet);
+ font-size: 12px;
+ line-height: 1.35;
+ text-align: center;
+ padding: 0 8px 4px;
+ }
+
+ body.phone-client.viewport-portrait.mode-display .screen-media {
+ width: 100%;
+ max-width: none;
+ height: auto;
+ max-height: min(52dvh, calc((100dvw - 16px) * 9 / 16));
+ aspect-ratio: 16 / 9;
+ object-fit: contain;
+ border-radius: 8px;
+ background: #05070a;
+ }
+
+ /* Sideboard: true single-column phone layout (not a squashed landscape board). */
+ body.phone-client.viewport-portrait .sideboard-view,
+ body.phone-client.viewport-portrait .quota-view {
+ width: 100%;
+ max-width: none;
+ justify-self: stretch;
+ align-self: start;
+ }
+
+ body.phone-client.viewport-portrait .sideboard-shell {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ grid-auto-rows: auto;
+ gap: 10px;
+ min-height: 0;
+ height: auto;
+ overflow: visible;
+ padding: 12px;
+ border-radius: 12px;
+ }
+
+ body.phone-client.viewport-portrait .sideboard-shell::before {
+ opacity: .42;
+ transform: none;
+ }
+
+ body.phone-client.viewport-portrait .sideboard-top {
+ grid-column: 1;
+ align-items: start;
+ }
+
+ body.phone-client.viewport-portrait .side-title {
+ display: grid;
+ gap: 4px;
+ white-space: normal;
+ }
+
+ body.phone-client.viewport-portrait .side-title strong {
+ font-size: clamp(20px, 6.4vw, 28px);
+ line-height: 1.1;
+ white-space: normal;
+ }
+
+ body.phone-client.viewport-portrait .side-title span {
+ white-space: normal;
+ font-size: 13px;
+ line-height: 1.35;
+ }
+
+ body.phone-client.viewport-portrait .side-load {
+ border-right: 0;
+ border-bottom: 0;
+ gap: 6px;
+ padding: 12px;
+ border-radius: 10px;
+ }
+
+ body.phone-client.viewport-portrait .side-big {
+ font-size: clamp(40px, 14vw, 64px);
+ line-height: .92;
+ }
+
+ body.phone-client.viewport-portrait .side-summary {
+ font-size: 13px;
+ line-height: 1.35;
+ }
+
+ body.phone-client.viewport-portrait .metric-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: none;
+ grid-auto-rows: minmax(76px, auto);
+ gap: 8px;
+ }
+
+ body.phone-client.viewport-portrait .metric-card {
+ padding: 10px;
+ border-radius: 10px;
+ min-height: 76px;
+ }
+
+ body.phone-client.viewport-portrait .metric-value {
+ font-size: clamp(22px, 7vw, 30px);
+ }
+
+ body.phone-client.viewport-portrait .metric-sub {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: unset;
+ font-size: 11px;
+ }
+
+ body.phone-client.viewport-portrait .side-feed {
+ grid-template-rows: none;
+ gap: 8px;
+ padding: 12px;
+ border-radius: 10px;
+ overflow: visible;
+ }
+
+ body.phone-client.viewport-portrait .feed-list {
+ overflow: visible;
+ gap: 6px;
+ font-size: 13px;
+ line-height: 1.35;
+ }
+
+ body.phone-client.viewport-portrait .feed-list li {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: unset;
+ }
+
+ body.phone-client.viewport-portrait .quota-shell {
+ min-height: 0;
+ height: auto;
+ overflow: visible;
+ border-radius: 12px;
+ padding: 12px;
+ }
+
+ body.phone-client.viewport-portrait .quota-top {
+ grid-template-columns: 1fr;
+ gap: 8px;
+ align-items: start;
+ }
+
+ body.phone-client.viewport-portrait .quota-title {
+ flex-wrap: wrap;
+ white-space: normal;
+ }
+
+ body.phone-client.viewport-portrait .quota-account-card {
+ border-radius: 10px;
+ }
+
+ body.ios-client.phone-client .utility-controls #keepAwake {
+ display: inline-flex !important;
+ }
+
+ body.phone-client #keepAwake,
+ body.phone-client #fullscreen {
+ min-height: 36px;
+ }
+
+ body.phone-client #keepAwake.active {
+ background: rgba(112, 217, 139, .22);
+ border-color: rgba(112, 217, 139, .5);
+ color: #9dffb8;
+ }
+
+ body.ios-client.phone-client.device-trusted header {
+ gap: 6px;
+ }
+
+ /* Phone preview (iOS + Android): leave chrome room outside fullscreen. */
+ body.phone-client.device-trusted.mode-display .display-view.active {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ place-items: center;
+ }
+
+ body.phone-client.device-trusted.mode-display .screen-media {
+ width: min(100%, 100dvw);
+ max-height: min(72dvh, 100%);
+ border-radius: 10px;
+ box-shadow: 0 12px 40px rgba(0, 0, 0, .35);
+ }
+
+ /*
+ * Fullscreen display on phone: iOS-proven geometry for Android too.
+ * Must beat the non-fullscreen max-height rules above (higher specificity).
+ */
+ body.phone-client.device-trusted.viewer-fullscreen.mode-display .screen-media {
+ width: var(--viewer-width);
+ height: var(--viewer-height);
+ max-width: none;
+ max-height: none;
+ aspect-ratio: auto;
+ object-fit: contain;
+ border: 0;
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ body.phone-client.device-trusted.viewer-fullscreen.mode-display .display-view.active {
+ width: 100%;
+ height: 100%;
+ min-height: 0;
+ place-items: center;
+ }
+
+ body.phone-client.device-trusted.mode-display .display-view.active::after {
+ content: var(--i18n-touch-hint, "單指操作 PC 滑鼠 · 連點兩下放大畫面");
+ color: var(--quiet);
+ font-size: 12px;
+ line-height: 1.35;
+ text-align: center;
+ padding: 6px 8px 0;
+ }
+
+ body.display-unavailable.phone-client.device-trusted.mode-display .display-view.active::after {
+ display: none;
+ }
+
+ body.phone-client.viewer-fullscreen.mode-display .display-view.active::after {
+ display: none;
+ }
+
+ .ios-home-tip {
+ display: none;
+ margin: 0;
+ padding: 10px 12px;
+ border: 1px solid rgba(143, 209, 255, .22);
+ border-radius: 10px;
+ background: rgba(8, 14, 20, .55);
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.4;
+ }
+
+ body.ios-client.phone-client:not(.device-trusted) .ios-home-tip,
+ body.ios-client.phone-client:not(.standalone-app) .ios-home-tip.show-install {
+ display: block;
+ }
+
+ body.ios-client.phone-client.device-trusted.standalone-app .ios-home-tip {
+ display: none !important;
+ }
+
+ .phone-pair-rescue {
+ display: none;
+ margin: 0 0 10px;
+ width: min(100%, 420px);
+ box-sizing: border-box;
+ padding: 22px 18px;
+ border: 1px solid rgba(255, 210, 122, .35);
+ border-radius: 12px;
+ background: rgba(40, 28, 8, .72);
+ color: #ffe6b0;
+ gap: 12px;
+ box-shadow: 0 18px 60px rgba(0, 0, 0, .3);
+ }
+
+ .phone-pair-rescue small {
+ color: #d7af5b;
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: .12em;
+ }
+
+ body.phone-client:not(.device-trusted):not(.pc-console) .phone-pair-rescue {
+ display: grid;
+ }
+
+ /* Keep the request action visible even while the phone client is still booting. */
+ body:not(.device-trusted):not(.pc-console) .phone-pair-rescue {
+ display: grid;
+ }
+
+ .phone-pair-rescue strong {
+ color: #fff4d6;
+ font-size: 24px;
+ }
+
+ .phone-pair-rescue > span {
+ color: #f2dca9;
+ line-height: 1.55;
+ }
+
+ .phone-pair-rescue .phone-pair-benefit {
+ padding-top: 10px;
+ border-top: 1px solid rgba(255, 230, 176, .2);
+ color: #d6c397;
+ font-size: 13px;
+ }
+
+ .pairing-locale-control {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 12px;
+ color: #f2dca9;
+ font-weight: 800;
+ }
+
+ .pairing-locale-control select {
+ min-width: 0;
+ min-height: 46px;
+ padding: 8px 12px;
+ border: 2px solid rgba(255, 255, 255, .34);
+ border-radius: 8px;
+ background: #fffdf4;
+ color: #171714;
+ font: inherit;
+ font-size: 17px;
+ }
+
+ body.eink-client .pairing-locale-control select {
+ min-height: 52px;
+ border: 3px solid #171714;
+ border-radius: 3px;
+ font-size: 20px;
+ }
+
+ .phone-pair-rescue input,
+ .phone-pair-rescue textarea {
+ width: 100%;
+ min-height: 44px;
+ box-sizing: border-box;
+ border: 1px solid rgba(255,255,255,.18);
+ border-radius: 8px;
+ background: rgba(0,0,0,.28);
+ color: #fff;
+ padding: 10px;
+ font-size: 14px;
+ }
+
+ .phone-pair-rescue button {
+ min-height: 50px;
+ border: 0;
+ border-radius: 10px;
+ background: #70d98b;
+ color: #0a1311;
+ font-weight: 800;
+ }
+
+ .pair-success-banner {
+ display: none;
+ position: sticky;
+ top: 0;
+ z-index: 40;
+ margin: 0 0 10px;
+ padding: 14px 12px;
+ border-radius: 12px;
+ border: 1px solid rgba(112, 217, 139, .45);
+ background: rgba(12, 36, 24, .94);
+ color: #d8ffe4;
+ font-size: 14px;
+ line-height: 1.4;
+ }
+
+ .pair-success-banner.show {
+ display: grid;
+ gap: 8px;
+ }
+
+ .pair-success-banner strong {
+ font-size: 18px;
+ color: #8dffb0;
+ }
+
+ /* Single full-screen path for iPhone on HTTP: must switch to HTTPS. */
+ #mobileHttpsGate {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ padding: calc(24px + var(--safe-area-inset-top, env(safe-area-inset-top))) 20px calc(24px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
+ background: #0d131a;
+ color: #f5f7fb;
+ overflow: auto;
+ }
+
+ body.mobile-http-blocked #mobileHttpsGate {
+ display: grid;
+ align-content: center;
+ gap: 14px;
+ max-width: 100%;
+ }
+
+ body.mobile-http-blocked > header,
+ body.mobile-http-blocked > main,
+ body.mobile-http-blocked > .exit-viewer {
+ display: none !important;
+ }
+
+ #mobileHttpsGate h1 {
+ margin: 0;
+ font-size: 26px;
+ line-height: 1.15;
+ }
+
+ #mobileHttpsGate p {
+ margin: 0;
+ color: #b9c4d0;
+ font-size: 15px;
+ line-height: 1.45;
+ }
+
+ #mobileHttpsGate ol {
+ margin: 0;
+ padding-left: 1.2em;
+ color: #dce7f4;
+ line-height: 1.5;
+ }
+
+ #mobileHttpsGate .gate-actions {
+ display: grid;
+ gap: 10px;
+ margin-top: 8px;
+ }
+
+ #mobileHttpsGate a,
+ #mobileHttpsGate button {
+ display: grid;
+ place-items: center;
+ min-height: 48px;
+ border-radius: 10px;
+ border: 0;
+ font-size: 16px;
+ font-weight: 800;
+ text-decoration: none;
+ }
+
+ #mobileHttpsGate .gate-primary {
+ background: #70d98b;
+ color: #0a1311;
+ }
+
+ #mobileHttpsGate .gate-secondary {
+ background: #223040;
+ color: #f5f7fb;
+ border: 1px solid rgba(143, 209, 255, .22);
+ }
+
+ #mobileHttpsGate .gate-note {
+ font-size: 13px;
+ color: #7f91a5;
+ }
+
+ @media (max-width: 760px) {
+ header {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(12px + var(--safe-area-inset-right, env(safe-area-inset-right))) 12px calc(12px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ }
+
+ .brand-mark {
+ width: 34px;
+ height: 34px;
+ font-size: 17px;
+ }
+
+ .brand-copy strong {
+ font-size: 18px;
+ }
+
+ .top-controls {
+ grid-template-columns: 1fr;
+ width: 100%;
+ justify-content: stretch;
+ }
+
+ .view-switcher {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ width: 100%;
+ }
+
+ .view-switcher .mode-button {
+ width: 100%;
+ }
+
+ .utility-controls {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ width: 100%;
+ }
+
+ .utility-controls select,
+ .utility-controls button {
+ width: 100%;
+ }
+
+ .utility-controls .status {
+ grid-column: 1 / -1;
+ justify-content: center;
+ }
+
+ .panel .controls {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .panel select,
+ .panel button,
+ .connect-panel {
+ grid-column: 1 / -1;
+ }
+
+ .connect-panel {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ padding: 12px;
+ }
+
+ .connect-qr {
+ grid-template-columns: 118px minmax(0, 1fr);
+ grid-template-rows: auto;
+ align-items: center;
+ justify-items: start;
+ padding: 10px;
+ }
+
+ .connect-panel img {
+ max-width: 118px;
+ padding: 6px;
+ }
+
+ .connect-qr span {
+ text-align: left;
+ font-size: 13px;
+ }
+
+ .connect-title strong {
+ font-size: 20px;
+ }
+
+ .pairing-guide {
+ grid-template-columns: 1fr;
+ gap: 7px;
+ }
+
+ .pairing-step {
+ min-height: 0;
+ padding: 10px;
+ }
+
+ .connect-actions {
+ grid-template-columns: 1fr;
+ }
+
+ .connect-actions a,
+ .connect-actions button {
+ width: 100%;
+ min-height: 44px;
+ }
+
+ .connect-status {
+ grid-template-columns: 1fr;
+ }
+
+ input {
+ width: 100%;
+ box-sizing: border-box;
+ }
+
+ .sideboard-shell {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto auto auto auto;
+ }
+
+ .quota-grid {
+ grid-template-columns: 1fr;
+ }
+ }
+
+ @media (max-width: 420px) {
+ .connect-qr {
+ grid-template-columns: 1fr;
+ justify-items: center;
+ }
+
+ .connect-qr span {
+ text-align: center;
+ }
+ }
+
+.pending-pairings {
+ display: grid;
+ gap: 8px;
+ margin-bottom: 12px;
+ padding: 12px;
+ border: 2px solid var(--green);
+ background: rgba(112, 217, 139, .1);
+}
+
+.pending-pairings[hidden] {
+ display: none !important;
+}
+
+.pending-pairing-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ gap: 8px;
+ align-items: center;
+ padding: 10px;
+ background: var(--surface-2);
+}
+
+.pending-pairing-row > div {
+ display: grid;
+ gap: 3px;
+}
+
+.pending-pairing-row span,
+.pending-pairing-row b {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.pending-pairing-row [data-pair-approve] {
+ background: var(--green);
+ color: #071015;
+}
+
+body.pc-console .connect-panel {
+ grid-template-columns: 1fr;
+}
+
diff --git a/src/PhoneMonitor.Host/wwwroot/css/20-pairing.css b/src/PhoneMonitor.Host/wwwroot/css/20-pairing.css
new file mode 100644
index 0000000..87cb452
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/css/20-pairing.css
@@ -0,0 +1,146 @@
+/* PC 本機預設精簡連線面板;一旦在配對流程就必須顯示 QR / 狀態 */
+body.pc-console:not(.pairing-active) .connect-qr,
+body.pc-console:not(.pairing-active) .pairing-guide,
+body.pc-console:not(.pairing-active) .connect-status,
+body.pc-console:not(.pairing-active) #httpsCertLink {
+ display: none !important;
+}
+
+body.pc-console.pairing-active .connect-panel {
+ grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
+ gap: 16px;
+ align-items: start;
+}
+
+body.pc-console.pairing-active .connect-qr {
+ display: grid !important;
+ visibility: visible !important;
+ opacity: 1 !important;
+}
+
+body.pc-console.pairing-active .connect-qr img,
+body.pc-console.pairing-active #qrCode {
+ display: block !important;
+ visibility: visible !important;
+ width: 100%;
+ max-width: 252px;
+ aspect-ratio: 1;
+ background: #fff;
+}
+
+body.pc-console.pairing-active .pairing-guide,
+body.pc-console.pairing-active .connect-status {
+ display: grid !important;
+}
+
+/* Cert install stays inside advanced connect details — do not promote it onto
+ the healthy pairing card. Failure path reuses .pairing-needs-advanced below. */
+
+body.trusted-public-endpoint #certificateSetupTitle,
+body.trusted-public-endpoint #httpsCertLink,
+body.trusted-public-endpoint #androidCertLink,
+body.trusted-public-endpoint #androidCertHelp {
+ display: none !important;
+}
+
+@media (max-width: 720px) {
+ .public-endpoint-controls {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .public-endpoint-controls input {
+ grid-column: 1 / -1;
+ }
+}
+
+/* Healthy pairing: hide advanced connect entirely (summary row included). */
+body.pc-console.pairing-active:not(.pairing-needs-advanced) .connect-links {
+ display: none !important;
+}
+
+/* Endpoint repair during pairing: keep advanced visible and mark the surface. */
+body.pc-console.pairing-active.pairing-needs-advanced .connect-links {
+ display: block !important;
+ border-color: var(--yellow);
+}
+
+body.pc-console.pairing-active.pairing-needs-advanced .connect-links[open] {
+ border-color: var(--yellow);
+}
+
+body.pc-console .connect-copy {
+ gap: 12px;
+}
+
+body.pc-console .connect-title strong {
+ font-size: 24px;
+}
+
+body.pc-console .connect-title span {
+ font-size: 15px;
+}
+
+body.pc-console:not(.deck-window) #einkModeToggle,
+body.pc-console:not(.deck-window) #keepAwake,
+body.pc-console:not(.deck-window) #fullscreen {
+ display: none !important;
+}
+
+body:not(.device-trusted):not(.pc-console) .display-empty-actions {
+ display: none !important;
+}
+
+/* Phone cannot install the virtual display — only Sideboard/Quota escape hatches. */
+body.phone-client:not(.pc-console) #installVirtualDisplay {
+ display: none !important;
+}
+
+body.phone-client:not(.pc-console) #openSideboardFromEmpty {
+ min-width: min(100%, 280px);
+}
+
+/* PC keeps Device setup as the primary empty-state action. */
+body.pc-console .display-empty-actions #installVirtualDisplay {
+ order: -1;
+}
+
+body.phone-client:not(.eink-client) .quota-toolbox {
+ justify-self: stretch;
+ display: flex;
+ flex-wrap: wrap;
+ border-radius: 8px;
+}
+
+body.phone-client:not(.eink-client) .quota-toolbox button {
+ flex: 1 1 calc(50% - 4px);
+ min-width: min(128px, 100%);
+ min-height: 40px;
+ padding: 7px 10px;
+ font-size: 12px;
+ line-height: 1.25;
+ white-space: normal;
+}
+
+/* Final E Ink legibility pass. Keep this block last so phone/skin rules cannot
+ * reintroduce low-resolution color text or narrow display fonts. */
+body.eink-client,
+body.eink-client button,
+body.eink-client select,
+body.eink-client input {
+ font-family: "Noto Sans TC", "Microsoft JhengHei", system-ui, sans-serif !important;
+}
+
+body.eink-client .sideboard-shell,
+body.eink-client .quota-shell {
+ --side-line: #000;
+ --side-panel: #fffef8;
+ --side-accent: #000;
+ --side-accent-2: #000;
+ --quota-ink: #000;
+ --quota-muted: #222;
+ --quota-panel: #fffef8;
+ color: #000 !important;
+ background: #f5f2e7 !important;
+ font-size: 18px;
+}
+
diff --git a/src/PhoneMonitor.Host/wwwroot/css/30-phone-dashboard.css b/src/PhoneMonitor.Host/wwwroot/css/30-phone-dashboard.css
new file mode 100644
index 0000000..93d3a79
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/css/30-phone-dashboard.css
@@ -0,0 +1,1185 @@
+/* Dedicated phone dashboard. Desktop and e-ink keep the configurable grid. */
+.mobile-overview {
+ display: none;
+}
+
+.mobile-detail-panel[hidden],
+.mobile-overview [data-mobile-section][hidden] {
+ display: none !important;
+}
+
+@media (max-width: 960px) {
+ body.phone-client:not(.eink-client).mode-sideboard .sideboard-top,
+ body.phone-client:not(.eink-client).mode-sideboard #dashboardEditBar,
+ body.phone-client:not(.eink-client).mode-sideboard #systemSideboardPage,
+ body.phone-client:not(.eink-client).mode-sideboard #customSideboardPage {
+ display: none !important;
+ }
+
+ /* Fullscreen is a mode-level action, not a landscape-only action. Keep it
+ * reachable from Display and Quota in portrait too; both views use the same
+ * header control. The !important intentionally overrides the older portrait
+ * compaction rule above. */
+ body.phone-client.device-trusted:not(.eink-client) .utility-controls #fullscreen {
+ display: inline-flex !important;
+ }
+
+ body.phone-client:not(.eink-client).mode-sideboard .sideboard-shell {
+ display: block;
+ height: auto;
+ min-height: 0;
+ padding: 0;
+ overflow: visible;
+ }
+
+ body.phone-client:not(.eink-client).mode-sideboard .mobile-overview {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+ width: 100%;
+ color: var(--side-text, #edf7f5);
+ }
+
+ .mobile-overview button {
+ min-height: 38px;
+ border: 1px solid var(--side-line, rgba(151, 225, 210, .24));
+ border-radius: 10px;
+ color: inherit;
+ background: rgba(11, 27, 32, .88);
+ font: inherit;
+ }
+
+ .mobile-overview-head,
+ .mobile-section-heading,
+ .mobile-overview-actions,
+ .mobile-quota-strip {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ }
+
+ .mobile-overview-head {
+ min-height: 42px;
+ padding: 2px 2px 0;
+ }
+
+ .mobile-overview-head > div:first-child {
+ display: grid;
+ gap: 1px;
+ }
+
+ .mobile-overview-head strong {
+ font-size: 19px;
+ line-height: 1.1;
+ }
+
+ #mobileConnectionState {
+ color: var(--side-muted, #9eb8b4);
+ font-size: 14px;
+ }
+
+ #mobileConnectionState.online {
+ color: var(--side-good, #80e8bd);
+ }
+
+ .mobile-overview-actions button,
+ .mobile-section-heading button {
+ min-height: 36px;
+ padding: 6px 10px;
+ font-size: 14px;
+ font-weight: 700;
+ }
+
+ .mobile-overview-section,
+ .mobile-detail-block,
+ .mobile-detail-host,
+ .mobile-detail-metrics article {
+ border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
+ border-radius: 12px;
+ background: linear-gradient(145deg, rgba(14, 34, 40, .96), rgba(7, 20, 25, .96));
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035);
+ }
+
+ .mobile-system-card {
+ display: grid;
+ gap: 3px;
+ min-height: 76px;
+ padding: 9px 11px;
+ }
+
+ .mobile-section-heading {
+ min-height: 28px;
+ color: var(--side-muted, #a5bbb8);
+ font-size: 14px;
+ }
+
+ #mobileSystemStatus {
+ font-size: 21px;
+ line-height: 1.12;
+ }
+
+ #mobileSystemReason {
+ overflow: hidden;
+ color: var(--side-muted, #a5bbb8);
+ font-size: 14px;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .mobile-core-metrics {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+ border: 0;
+ background: none;
+ box-shadow: none;
+ }
+
+ .mobile-metric-card {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 4px 8px;
+ min-width: 0;
+ min-height: 64px;
+ padding: 8px 10px;
+ border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
+ border-radius: 11px;
+ background: rgba(8, 24, 29, .92);
+ }
+
+ .mobile-metric-card > span {
+ color: var(--side-muted, #a5bbb8);
+ font-size: 15px;
+ font-weight: 700;
+ }
+
+ .mobile-metric-card > strong {
+ font-size: 23px;
+ line-height: 1;
+ }
+
+ .mobile-mini-bar {
+ grid-column: 1 / -1;
+ height: 5px;
+ overflow: hidden;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, .1);
+ }
+
+ .mobile-mini-bar > span {
+ display: block;
+ width: 0;
+ height: 100%;
+ border-radius: inherit;
+ background: var(--side-accent, #63ddc7);
+ transition: width .2s ease;
+ }
+
+ .mobile-activity-preview {
+ min-height: 108px;
+ padding: 8px 10px;
+ overflow: hidden;
+ }
+
+ #mobileActivityPreview,
+ .mobile-activity-list,
+ #mobileProcessList {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ }
+
+ #mobileActivityPreview {
+ display: grid;
+ gap: 5px;
+ margin-top: 4px;
+ }
+
+ #mobileActivityPreview > li {
+ min-width: 0;
+ max-height: 62px;
+ padding: 5px 7px;
+ overflow: hidden;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, .045);
+ font-size: 14px;
+ line-height: 1.25;
+ }
+
+ #mobileActivityPreview .activity-feed-body,
+ #mobileActivityPreview .activity-feed-message,
+ #mobileActivityPreview .activity-feed-text {
+ display: -webkit-box;
+ overflow: hidden;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+ }
+
+ #mobileActivityPreview .activity-feed-meta {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .mobile-quota-strip {
+ width: 100%;
+ min-height: 64px;
+ padding: 8px 11px;
+ text-align: left;
+ }
+
+ .mobile-quota-strip > span {
+ display: grid;
+ gap: 2px;
+ }
+
+ .mobile-quota-strip > span:last-child {
+ justify-items: end;
+ text-align: right;
+ }
+
+ .mobile-quota-strip small {
+ color: var(--side-muted, #a5bbb8);
+ font-size: 13px;
+ }
+
+ #mobileQuotaValue {
+ font-size: 25px;
+ }
+
+ #mobileQuotaReset {
+ font-size: 14px;
+ }
+
+ .mobile-detail-panel {
+ position: fixed;
+ inset: 0;
+ z-index: 2200;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(12px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
+ overflow-y: auto;
+ color: #edf7f5;
+ background: #071116;
+ overscroll-behavior: contain;
+ }
+
+ .mobile-detail-header {
+ position: sticky;
+ top: calc(-10px - var(--safe-area-inset-top, env(safe-area-inset-top)));
+ z-index: 2;
+ display: grid;
+ grid-template-columns: minmax(76px, 1fr) auto minmax(76px, 1fr);
+ align-items: center;
+ min-height: 48px;
+ padding: 4px 0;
+ background: #071116;
+ }
+
+ .mobile-detail-header > strong {
+ font-size: 19px;
+ text-align: center;
+ }
+
+ .mobile-detail-header > button:first-child {
+ justify-self: start;
+ }
+
+ .mobile-detail-header > button:last-child {
+ justify-self: end;
+ }
+
+ .mobile-activity-filters {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 7px;
+ }
+
+ .mobile-activity-filters button.active {
+ border-color: var(--side-accent, #63ddc7);
+ color: #06110f;
+ background: var(--side-accent, #63ddc7);
+ }
+
+ .mobile-activity-list {
+ display: grid;
+ gap: 8px;
+ min-height: 0;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+ }
+
+ .mobile-activity-list > li {
+ padding: 10px;
+ border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
+ border-radius: 10px;
+ background: rgba(12, 29, 35, .9);
+ font-size: 15px;
+ line-height: 1.4;
+ }
+
+ .mobile-detail-host,
+ .mobile-detail-block {
+ display: grid;
+ gap: 6px;
+ padding: 11px;
+ font-size: 15px;
+ }
+
+ .mobile-detail-metrics {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ }
+
+ .mobile-detail-metrics article {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ padding: 11px;
+ }
+
+ .mobile-detail-metrics strong {
+ font-size: 21px;
+ }
+
+ .mobile-detail-metrics small,
+ .mobile-detail-block > span {
+ color: var(--side-muted, #a5bbb8);
+ font-size: 14px;
+ }
+
+ #mobileProcessList,
+ #mobileCustomCards {
+ display: grid;
+ gap: 6px;
+ }
+
+ #mobileProcessList > li,
+ #mobileCustomCards > article {
+ padding: 7px 8px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, .045);
+ }
+
+ .mobile-layout-list {
+ display: grid;
+ gap: 8px;
+ }
+
+ .mobile-layout-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-height: 52px;
+ padding: 8px 10px;
+ border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
+ border-radius: 10px;
+ background: rgba(12, 29, 35, .9);
+ }
+
+ .mobile-layout-row label {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 16px;
+ }
+
+ .mobile-layout-row > div {
+ display: flex;
+ gap: 6px;
+ }
+
+ .mobile-layout-row button {
+ min-width: 42px;
+ }
+
+ .mobile-layout-reset {
+ margin-top: auto;
+ }
+
+ body.mobile-detail-open {
+ overflow: hidden !important;
+ }
+}
+
+/* BOOX connection state: readable at a glance and never confused with a
+ remote-display warning (e-readers do not use the virtual display). */
+body.eink-client .status {
+ min-height: 48px;
+ padding: 8px 14px !important;
+ border: 3px solid #000 !important;
+ border-radius: 4px !important;
+ background: #fffef8 !important;
+ color: #000 !important;
+ font-size: clamp(22px, 4vw, 32px) !important;
+ font-weight: 900 !important;
+ letter-spacing: .04em;
+ box-shadow: none !important;
+}
+
+body.eink-client .status .dot,
+body.eink-client .status .dot.online {
+ width: 16px;
+ height: 16px;
+ border: 2px solid #000;
+ background: #000 !important;
+ box-shadow: none !important;
+}
+
+.eink-connection-state {
+ display: none;
+}
+
+body.eink-client .eink-connection-state {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ padding: 4px 12px;
+ border: 3px solid #000;
+ border-radius: 4px;
+ background: #000;
+ color: #fffef8;
+ font-size: clamp(22px, 3.8vw, 32px);
+ font-weight: 900;
+ line-height: 1;
+ letter-spacing: .04em;
+ white-space: nowrap;
+}
+
+body.eink-client .eink-connection-state.offline {
+ background: #fffef8;
+ color: #000;
+}
+
+/* E Ink clients still need explicit escape hatches from the dense panel.
+ The generic trusted-phone rule hides the utility row; keep the mode toggle
+ available so an accidentally enabled paper layout can always be disabled. */
+body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls {
+ display: flex !important;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls > * {
+ display: none !important;
+}
+
+body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls #einkModeToggle,
+body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls #fullscreen {
+ display: inline-flex !important;
+ align-items: center;
+ justify-content: center;
+ min-height: 44px;
+ padding: 6px 12px;
+ border: 3px solid #000;
+ border-radius: 3px;
+ background: #fffef8;
+ color: #000;
+ font-size: 20px;
+ font-weight: 900;
+}
+
+body.eink-client .quota-top {
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+}
+
+body.eink-client.viewport-portrait .quota-top {
+ grid-template-columns: minmax(0, 1fr) auto;
+}
+
+body.eink-client.viewport-portrait .quota-left {
+ grid-column: 1 / -1;
+}
+
+body.eink-client .skin-picker {
+ display: none !important;
+}
+
+body.eink-client .sideboard-shell {
+ align-content: start;
+ grid-template-columns: 1fr !important;
+ grid-template-rows: auto !important;
+ grid-auto-rows: max-content !important;
+ gap: 14px !important;
+ padding: 16px 14px 28px !important;
+ min-height: 0 !important;
+ height: auto !important;
+ overflow: visible !important;
+}
+
+/* Title + page tabs share one header row (name left, tabs right). */
+body.eink-client .sideboard-top {
+ grid-column: 1 / -1 !important;
+ display: flex !important;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ min-width: 0 !important;
+ max-width: 100% !important;
+ min-height: 0;
+ padding: 4px 0 12px !important;
+ border-bottom: 3px solid #24241f !important;
+ background: transparent;
+ overflow: visible !important;
+}
+
+body.eink-client .sideboard-page-tabs {
+ display: flex !important;
+ flex: 0 0 auto;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 8px !important;
+ min-width: 0 !important;
+ max-width: none !important;
+ margin: 0 !important;
+}
+
+body.eink-client .side-load,
+body.eink-client .metric-grid,
+body.eink-client .side-feed,
+body.eink-client .custom-sideboard-page {
+ grid-column: 1 / -1 !important;
+ min-width: 0 !important;
+ max-width: 100% !important;
+}
+
+body.eink-client .side-title {
+ display: grid;
+ flex: 1 1 auto;
+ gap: 4px;
+ white-space: normal;
+ min-width: 0;
+ overflow: visible !important;
+}
+
+body.eink-client .side-title strong {
+ color: #000 !important;
+ font-size: clamp(24px, 5.8vw, 36px) !important;
+ line-height: 1.2 !important;
+ white-space: normal;
+ overflow: visible !important;
+ overflow-wrap: anywhere;
+ /* Keep glyph tops from being clipped by parent overflow/fullscreen edges. */
+ padding-top: 2px;
+}
+
+body.eink-client .side-title span {
+ line-height: 1.35 !important;
+}
+
+/* Fullscreen e-ink panel follows the real safe area. The earlier fixed 96px
+ workaround created a large blank forehead in BOOX's installed PWA. */
+body.eink-client.dashboard-viewer,
+body.eink-client.viewer-immersive {
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+ -webkit-overflow-scrolling: touch;
+ padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) !important;
+ padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
+ padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
+ padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
+ box-sizing: border-box !important;
+}
+
+body.eink-client.dashboard-viewer .sideboard-shell,
+body.eink-client.viewer-immersive .sideboard-shell,
+body.eink-client.viewer-fullscreen.mode-sideboard .sideboard-shell {
+ padding-top: 12px !important;
+ padding-left: 14px !important;
+ padding-right: 14px !important;
+ padding-bottom: 28px !important;
+}
+
+body.eink-client.dashboard-viewer .sideboard-top,
+body.eink-client.viewer-immersive .sideboard-top {
+ /* BOOX Chromium collapses this flex item to its border/padding contribution
+ inside the fullscreen grid. Its children then center above the grid track
+ and end up underneath the browser's 96px fullscreen strip. */
+ min-height: 72px !important;
+ padding-top: 6px !important;
+}
+
+body.eink-client.dashboard-viewer .side-title strong,
+body.eink-client.viewer-immersive .side-title strong {
+ line-height: 1.25 !important;
+ padding-top: 0 !important;
+}
+
+body.eink-client .side-title span,
+body.eink-client .side-summary,
+body.eink-client .side-summary span,
+body.eink-client .metric-sub,
+body.eink-client .feed-list,
+body.eink-client .feed-list li,
+body.eink-client .feed-list span,
+body.eink-client .quota-title span,
+body.eink-client .quota-status,
+body.eink-client .quota-window span,
+body.eink-client .quota-window small,
+body.eink-client .quota-time,
+body.eink-client .quota-action-status {
+ color: #000 !important;
+ font-size: 18px !important;
+ line-height: 1.45 !important;
+ text-shadow: none !important;
+}
+
+body.eink-client .side-panel,
+body.eink-client .metric-card,
+body.eink-client .quota-account-card,
+body.eink-client .quota-local-card {
+ background: #fffef8 !important;
+ border: 3px solid #000 !important;
+ border-radius: 4px !important;
+ backdrop-filter: none !important;
+ -webkit-backdrop-filter: none !important;
+}
+
+body.eink-client .side-load {
+ display: grid !important;
+ align-content: start !important;
+ gap: 8px !important;
+ min-height: 0 !important;
+ padding: 16px !important;
+ overflow: visible !important;
+}
+
+body.eink-client .side-kicker,
+body.eink-client .feed-heading,
+body.eink-client .quota-provider-title {
+ color: #000 !important;
+ font-size: 15px !important;
+ font-weight: 800 !important;
+ letter-spacing: .02em;
+}
+
+body.eink-client .side-big {
+ color: #000 !important;
+ font-family: inherit !important;
+ font-size: clamp(52px, 14vw, 72px) !important;
+ line-height: .95 !important;
+ overflow: visible !important;
+}
+
+body.eink-client .side-summary {
+ overflow: visible !important;
+ white-space: normal !important;
+}
+
+body.eink-client .side-summary span {
+ white-space: normal !important;
+ overflow: visible !important;
+ text-overflow: unset !important;
+}
+
+body.eink-client .metric-grid {
+ display: grid !important;
+ grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
+ grid-template-rows: none !important;
+ grid-auto-rows: minmax(108px, auto) !important;
+ gap: 10px !important;
+ min-height: 0 !important;
+ overflow: visible !important;
+}
+
+body.eink-client .metric-card {
+ min-height: 108px !important;
+ height: auto !important;
+ padding: 12px !important;
+ gap: 6px 10px !important;
+ overflow: visible !important;
+ align-content: start;
+}
+
+body.eink-client .metric-value,
+body.eink-client .quota-window b {
+ color: #000 !important;
+ font-family: inherit !important;
+ font-size: clamp(28px, 7vw, 40px) !important;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -.03em;
+ white-space: nowrap;
+}
+
+body.eink-client .metric-sub {
+ white-space: normal !important;
+ overflow: visible !important;
+ text-overflow: unset !important;
+ font-size: 14px !important;
+ line-height: 1.3 !important;
+}
+
+body.eink-client .mini-bar,
+body.eink-client .quota-bar {
+ height: 14px !important;
+ border: 2px solid #000 !important;
+ background: #e8e3d3 !important;
+}
+
+body.eink-client .mini-bar span,
+body.eink-client .quota-bar i {
+ background: #000 !important;
+ transition: none !important;
+}
+
+body.eink-client .side-feed {
+ display: grid !important;
+ grid-template-rows: none !important;
+ align-content: start;
+ gap: 10px !important;
+ padding: 16px !important;
+ overflow: visible !important;
+ min-height: 0 !important;
+}
+
+body.eink-client .feed-list {
+ gap: 8px !important;
+ overflow: visible !important;
+ min-height: 0 !important;
+ font-size: 16px !important;
+ line-height: 1.4 !important;
+}
+
+body.eink-client .feed-list li {
+ padding: 8px 0 !important;
+ border: 0 !important;
+ border-bottom: 1px solid #777 !important;
+ background: transparent !important;
+ white-space: normal !important;
+ overflow: visible !important;
+ text-overflow: unset !important;
+}
+
+body.eink-client .quota-title strong,
+body.eink-client .quota-account-email {
+ color: #000 !important;
+ font-family: inherit !important;
+}
+
+/* Unified editable dashboard -------------------------------------------------
+ System metrics and custom cards share one persisted 12-column canvas. */
+.dashboard-edit-toggle {
+ min-height: 34px;
+ padding: 6px 12px;
+ border: 1px solid var(--side-line);
+ border-radius: 4px;
+ background: rgba(255,255,255,.08);
+ color: inherit;
+ font-weight: 700;
+}
+
+.dashboard-edit-toggle[aria-pressed="true"] {
+ background: var(--side-accent);
+ color: #071015;
+ border-color: var(--side-accent);
+}
+
+.dashboard-edit-bar {
+ grid-column: 1 / -1;
+ display: grid;
+ grid-template-columns: minmax(180px, 1fr) auto;
+ align-items: center;
+ gap: 8px 14px;
+ padding: 8px 10px;
+ border: 1px solid var(--side-line);
+ background: rgba(7, 16, 21, .96);
+ z-index: 30 !important;
+}
+
+.dashboard-edit-bar[hidden] { display: none !important; }
+
+.dashboard-edit-copy {
+ display: grid;
+ gap: 2px;
+}
+
+.dashboard-edit-copy strong { color: var(--side-accent); }
+.dashboard-edit-copy span { color: rgba(247,242,232,.72); font-size: 12px; }
+.dashboard-edit-status.error { color: var(--side-warn); }
+
+.dashboard-selection-controls {
+ grid-column: 1 / -1;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ padding-bottom: 8px;
+ border-bottom: 1px solid var(--side-line);
+}
+
+.dashboard-selection-controls[hidden] { display: none !important; }
+
+.dashboard-selection-controls strong {
+ min-width: min(180px, 100%);
+ margin-right: auto;
+ color: var(--side-accent);
+}
+
+.dashboard-selection-controls button {
+ min-width: 42px;
+ min-height: 34px;
+ padding: 5px 9px;
+ border: 1px solid rgba(255,255,255,.3);
+ border-radius: 3px;
+ background: #162229;
+ color: #fff;
+ touch-action: none;
+}
+
+.dashboard-edit-actions,
+.dashboard-card-library {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+
+.dashboard-edit-actions button,
+.dashboard-card-library button {
+ min-height: 32px;
+ padding: 5px 9px;
+ border-radius: 4px;
+ background: rgba(255,255,255,.08);
+ color: inherit;
+ border-color: var(--side-line);
+}
+
+.dashboard-card-library {
+ grid-column: 1 / -1;
+ padding-top: 8px;
+ border-top: 1px solid var(--side-line);
+}
+
+.dashboard-card-library[hidden] { display: none !important; }
+.dashboard-library-empty { color: rgba(247,242,232,.62); font-size: 12px; }
+
+#systemSideboardPage.dashboard-grid {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: repeat(12, minmax(0, 1fr));
+ grid-template-rows: repeat(6, minmax(0, 1fr));
+ gap: 8px;
+ min-width: 0;
+ min-height: 680px;
+ isolation: isolate;
+}
+
+/* Desktop control and deck windows are one-screen dashboards. The previous
+ 680px minimum made a short PC window grow with the notification feed even
+ though the feed itself was scrollable. Constrain the dashboard to the
+ available viewport and let individual cards own their scrolling. */
+body.pc-console.mode-sideboard {
+ height: 100dvh;
+ min-height: 0;
+ overflow: hidden;
+}
+
+body.pc-console.mode-sideboard main {
+ min-height: 0;
+ overflow: hidden;
+}
+
+body.pc-console.mode-sideboard .sideboard-view.active {
+ align-self: stretch;
+ height: 100%;
+ min-height: 0;
+}
+
+body.pc-console.mode-sideboard .sideboard-shell {
+ height: 100%;
+ min-height: 0;
+}
+
+body.pc-console.mode-sideboard #systemSideboardPage.dashboard-grid,
+body.deck-window.mode-sideboard #systemSideboardPage.dashboard-grid {
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+}
+
+#systemSideboardPage.dashboard-grid > .metric-grid,
+#systemSideboardPage.dashboard-grid > .custom-cards-grid {
+ display: contents !important;
+}
+
+#systemSideboardPage.dashboard-grid [data-dashboard-key] {
+ position: relative;
+ z-index: 1;
+ min-width: 0;
+ min-height: 0;
+ height: auto;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+#systemSideboardPage.dashboard-grid .dashboard-card-hidden {
+ display: none !important;
+}
+
+.dashboard-feed-card {
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.dashboard-card-editor {
+ position: absolute !important;
+ inset: 6px 6px auto auto;
+ z-index: 50 !important;
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ border: 1px solid var(--side-accent);
+ background: #071015;
+ box-shadow: 0 3px 12px rgba(0,0,0,.45);
+}
+
+.dashboard-card-editor button {
+ min-width: 30px;
+ min-height: 30px;
+ padding: 3px 7px;
+ border: 1px solid rgba(255,255,255,.3);
+ border-radius: 2px;
+ background: #162229;
+ color: #fff;
+ font-size: 11px;
+ touch-action: none;
+}
+
+.dashboard-card-editor .dashboard-drag-handle {
+ cursor: grab;
+ color: var(--side-accent);
+}
+
+.dashboard-card-dragging {
+ z-index: 40 !important;
+ opacity: .78;
+ outline: 3px solid var(--side-accent);
+ pointer-events: none;
+}
+
+.dashboard-editing #systemSideboardPage.dashboard-grid {
+ background-image:
+ linear-gradient(to right, rgba(255,255,255,.055) 1px, transparent 1px),
+ linear-gradient(to bottom, rgba(255,255,255,.055) 1px, transparent 1px);
+ background-size: calc(100% / 12) calc(100% / 6);
+ outline: 1px dashed rgba(255,255,255,.2);
+}
+
+.dashboard-editing #systemSideboardPage.dashboard-grid [data-dashboard-key] {
+ cursor: pointer;
+}
+
+.dashboard-editing #systemSideboardPage.dashboard-grid .dashboard-card-selected {
+ cursor: grab;
+ touch-action: none;
+}
+
+.dashboard-editing #systemSideboardPage.dashboard-grid .dashboard-card-selected {
+ z-index: 2;
+ outline: 3px solid var(--side-accent);
+ outline-offset: -3px;
+}
+
+.dashboard-config-panel {
+ position: absolute !important;
+ inset: 76px 12px 12px;
+ z-index: 60 !important;
+ padding: 12px;
+ border: 1px solid var(--side-line);
+ background: rgba(7, 16, 21, .985);
+ box-shadow: 0 18px 60px rgba(0,0,0,.62);
+}
+
+body.eink-client .sideboard-shell {
+ grid-template-columns: 1fr !important;
+ grid-template-rows: auto minmax(0, 1fr) !important;
+ grid-auto-rows: unset !important;
+ gap: 8px !important;
+ height: var(--viewer-height, 100dvh) !important;
+ min-height: var(--viewer-height, 100dvh) !important;
+ padding: 10px 14px 12px !important;
+ overflow: hidden !important;
+}
+
+body.eink-client .sideboard-shell.dashboard-editing {
+ grid-template-rows: auto auto minmax(0, 1fr) !important;
+}
+
+body.eink-client .sideboard-top {
+ min-height: 0 !important;
+ height: auto !important;
+ padding: 2px 0 8px !important;
+ border-bottom-width: 2px !important;
+}
+
+body.eink-client.dashboard-viewer .sideboard-top,
+body.eink-client.viewer-immersive .sideboard-top {
+ min-height: 0 !important;
+ padding-top: 2px !important;
+}
+
+body.eink-client .side-title strong {
+ font-size: clamp(24px, 4vw, 34px) !important;
+ line-height: 1.1 !important;
+}
+
+body.eink-client .side-title span {
+ font-size: 17px !important;
+ line-height: 1.2 !important;
+}
+
+body.eink-client .dashboard-edit-toggle {
+ flex: 0 0 auto;
+ min-height: 46px;
+ padding: 6px 14px;
+ border: 3px solid #000;
+ background: #fffef8;
+ color: #000;
+ font-size: 17px;
+}
+
+body.eink-client .dashboard-edit-toggle[aria-pressed="true"] {
+ background: #000;
+ color: #fffef8;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid {
+ display: grid !important;
+ grid-column: 1 / -1 !important;
+ grid-template-columns: repeat(12, minmax(0, 1fr)) !important;
+ grid-template-rows: repeat(6, minmax(0, 1fr)) !important;
+ grid-auto-rows: unset !important;
+ gap: 8px !important;
+ min-height: 0 !important;
+ height: 100% !important;
+ overflow: hidden !important;
+}
+
+body.eink-client.viewport-portrait #systemSideboardPage.dashboard-grid {
+ grid-template-rows: repeat(10, minmax(0, 1fr)) !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .side-load,
+body.eink-client #systemSideboardPage.dashboard-grid .metric-card,
+body.eink-client #systemSideboardPage.dashboard-grid .side-feed,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card {
+ min-height: 0 !important;
+ height: auto !important;
+ padding: 10px !important;
+ overflow: hidden !important;
+ border-width: 2px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .side-load {
+ align-content: center !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .metric-card {
+ gap: 4px 7px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .metric-value {
+ font-size: clamp(28px, 3.8vw, 44px) !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .metric-sub {
+ font-size: clamp(12px, 1.3vw, 16px) !important;
+ line-height: 1.2 !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .mini-bar {
+ height: 10px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .side-feed {
+ display: grid !important;
+ grid-template-rows: auto minmax(0, 1fr) !important;
+ align-content: stretch !important;
+ gap: 6px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .feed-list {
+ gap: 3px !important;
+ overflow: hidden !important;
+ font-size: clamp(13px, 1.4vw, 17px) !important;
+ line-height: 1.25 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .feed-list li {
+ padding: 3px 0 !important;
+ white-space: nowrap !important;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-cards-grid {
+ display: contents !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-header,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-footer {
+ flex: 0 0 auto;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list {
+ min-height: 0;
+ overflow: hidden !important;
+}
+
+body.eink-client .dashboard-edit-bar,
+body.eink-client .dashboard-config-panel {
+ border: 3px solid #000;
+ background: #fffef8;
+ color: #000;
+ box-shadow: none;
+}
+
+body.eink-client .dashboard-edit-bar {
+ grid-template-columns: 1fr;
+ padding: 8px;
+}
+
+body.eink-client .dashboard-edit-copy span,
+body.eink-client .dashboard-library-empty { color: #000; }
+
+body.eink-client .dashboard-edit-actions button,
+body.eink-client .dashboard-card-library button,
+body.eink-client .dashboard-selection-controls button,
+body.eink-client .dashboard-card-editor button {
+ border: 2px solid #000;
+ background: #fffef8;
+ color: #000;
+ font-size: 14px;
+}
+
+body.eink-client .dashboard-card-editor {
+ border: 2px solid #000;
+ background: #fffef8;
+ box-shadow: none;
+}
+
+body.eink-client .dashboard-config-panel {
+ inset: 70px 10px 10px;
+ overflow: auto;
+}
+
+body.dashboard-edit-mode.eink-client.dashboard-viewer,
+body.dashboard-edit-mode.eink-client.viewer-immersive {
+ overflow: hidden !important;
+}
+
diff --git a/src/PhoneMonitor.Host/wwwroot/css/40-eink-sideboard.css b/src/PhoneMonitor.Host/wwwroot/css/40-eink-sideboard.css
new file mode 100644
index 0000000..2cc87a5
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/css/40-eink-sideboard.css
@@ -0,0 +1,629 @@
+/* E-ink sideboard is always single-column; no orientation-dependent board. */
+
+/* Custom cards follow the same readable, single-column rules on small and
+ * monochrome clients as the built-in sideboard pages. */
+body.phone-client.viewport-portrait .sideboard-page-tabs {
+ grid-column: 1;
+ overflow-x: auto;
+ padding-bottom: 2px;
+}
+
+body.phone-client.viewport-portrait .custom-sideboard-page {
+ grid-column: 1;
+ grid-template-rows: auto auto auto;
+ gap: 10px;
+}
+
+body.phone-client.viewport-portrait .custom-cards-top,
+body.phone-client.viewport-portrait .custom-manager-header {
+ align-items: stretch;
+ flex-direction: column;
+}
+
+body.phone-client.viewport-portrait .custom-cards-toolbar,
+body.phone-client.viewport-portrait .custom-source-actions,
+body.phone-client.viewport-portrait .custom-source-form-actions {
+ justify-content: stretch;
+}
+
+body.phone-client.viewport-portrait .custom-cards-toolbar button,
+body.phone-client.viewport-portrait .custom-manager-header button,
+body.phone-client.viewport-portrait .custom-source-actions button,
+body.phone-client.viewport-portrait .custom-source-form-actions button {
+ flex: 1 1 auto;
+ min-height: 40px;
+}
+
+body.phone-client.viewport-portrait .custom-cards-grid {
+ grid-template-columns: 1fr;
+ overflow: visible;
+}
+
+body.phone-client.viewport-portrait .custom-source-row {
+ align-items: stretch;
+ flex-direction: column;
+}
+
+body.phone-client.viewport-portrait .custom-source-actions {
+ justify-content: stretch;
+}
+
+body.phone-client.viewport-portrait .custom-source-form {
+ grid-template-columns: 1fr;
+}
+
+body.phone-client.viewport-portrait .custom-source-form > strong,
+body.phone-client.viewport-portrait .custom-credential-panel > strong,
+body.phone-client.viewport-portrait .custom-credential-panel > span,
+body.phone-client.viewport-portrait .custom-credential-panel > textarea,
+body.phone-client.viewport-portrait .custom-source-form-actions {
+ grid-column: 1;
+}
+
+body.eink-client .sideboard-page-tabs {
+ gap: 8px;
+}
+
+body.eink-client .sideboard-page-tabs button,
+body.eink-client .custom-cards-toolbar button,
+body.eink-client .custom-manager-header button,
+body.eink-client .custom-source-actions button,
+body.eink-client .custom-source-form-actions button {
+ min-height: 44px;
+ padding: 6px 12px;
+ border: 3px solid #000 !important;
+ border-radius: 4px;
+ background: #fffef8 !important;
+ color: #000 !important;
+ font-size: 16px !important;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+body.eink-client .sideboard-page-tabs button.active {
+ background: #000 !important;
+ color: #fffef8 !important;
+}
+
+body.eink-client .custom-sideboard-page,
+body.eink-client .custom-cards-grid,
+body.eink-client .custom-sources-manager {
+ gap: 16px !important;
+}
+
+body.eink-client .custom-cards-title strong,
+body.eink-client .custom-manager-header strong,
+body.eink-client .custom-card-title,
+body.eink-client .custom-status-value,
+body.eink-client .custom-metric-value,
+body.eink-client .custom-card-empty,
+body.eink-client .custom-status-detail,
+body.eink-client .custom-feed-text,
+body.eink-client .custom-key-value-list dd,
+body.eink-client .custom-card-footer,
+body.eink-client .custom-card-source,
+body.eink-client .custom-feed-meta,
+body.eink-client .custom-cards-title span,
+body.eink-client .custom-manager-header span,
+body.eink-client .custom-source-meta,
+body.eink-client .custom-source-health,
+body.eink-client .custom-source-form label,
+body.eink-client .custom-credential-panel > span,
+body.eink-client .custom-key-value-list dt {
+ color: #000 !important;
+ font-size: 18px !important;
+ line-height: 1.45 !important;
+ text-shadow: none !important;
+}
+
+body.eink-client .custom-card,
+body.eink-client .custom-empty-state,
+body.eink-client .custom-sources-manager,
+body.eink-client .custom-credential-panel,
+body.eink-client .custom-source-row,
+body.eink-client .custom-feed-item {
+ background: #fffef8 !important;
+ border: 3px solid #000 !important;
+ border-radius: 4px !important;
+ box-shadow: none !important;
+}
+
+body.eink-client .custom-card {
+ min-height: 190px;
+ padding: 16px;
+}
+
+body.eink-client .custom-card-title,
+body.eink-client .custom-status-value,
+body.eink-client .custom-metric-value,
+body.eink-client .custom-cards-title strong,
+body.eink-client .custom-manager-header strong {
+ font-size: 28px !important;
+ font-weight: 800;
+}
+
+body.eink-client .custom-card-freshness {
+ border: 2px solid #000 !important;
+ color: #000 !important;
+ font-size: 16px !important;
+}
+
+body.eink-client .custom-feed-list,
+body.eink-client .custom-key-value-list {
+ gap: 10px !important;
+ font-size: 18px !important;
+}
+
+body.eink-client .custom-feed-item {
+ border-left: 10px solid #000 !important;
+ padding: 10px !important;
+}
+
+body.eink-client .custom-progress {
+ height: 14px !important;
+ border: 2px solid #000 !important;
+ background: #e8e3d3 !important;
+}
+
+body.eink-client .custom-progress span {
+ background: #000 !important;
+}
+
+body.eink-client .custom-source-form input,
+body.eink-client .custom-source-form select,
+body.eink-client .custom-credential-panel textarea {
+ min-height: 48px;
+ border: 3px solid #000 !important;
+ border-radius: 4px;
+ background: #fffef8 !important;
+ color: #000 !important;
+ font-size: 18px !important;
+}
+
+/* Edit mode intentionally allows vertical movement; read mode remains the
+ strict one-screen canvas above. Keep this last so legacy fullscreen rules
+ cannot lock the editor to a clipped 100dvh box. */
+body.dashboard-edit-mode.eink-client.dashboard-viewer,
+body.dashboard-edit-mode.eink-client.viewer-immersive {
+ height: auto !important;
+ min-height: 100dvh !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+ position: absolute !important;
+}
+
+body.dashboard-edit-mode.eink-client main,
+body.dashboard-edit-mode.eink-client .sideboard-view.active {
+ height: auto !important;
+ min-height: 100dvh !important;
+ overflow: visible !important;
+}
+
+body.dashboard-edit-mode.eink-client .sideboard-shell.dashboard-editing {
+ height: auto !important;
+ min-height: 100dvh !important;
+ overflow: visible !important;
+}
+
+body.dashboard-edit-mode.eink-client .sideboard-shell.dashboard-editing #systemSideboardPage.dashboard-grid {
+ height: calc(100dvh - 92px) !important;
+ min-height: 820px !important;
+}
+
+/* BOOX fallback: Chrome can preserve an old install-time landscape lock even
+ when Android auto-rotate is enabled. The motion sensor marks the physical
+ placement and this rotates a portrait-sized dashboard canvas into it. */
+html.eink-sensor-portrait {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+body.eink-client.eink-sensor-portrait {
+ position: fixed !important;
+ width: 100dvh !important;
+ height: 100dvw !important;
+ max-width: none !important;
+ max-height: none !important;
+ margin: 0 !important;
+ overflow: hidden !important;
+ transform-origin: top left !important;
+}
+
+body.eink-client.eink-sensor-portrait-primary {
+ top: 100% !important;
+ left: 0 !important;
+ transform: rotate(-90deg) !important;
+}
+
+body.eink-client.eink-sensor-portrait-secondary {
+ top: 0 !important;
+ left: 100% !important;
+ transform: rotate(90deg) !important;
+}
+
+/* One-screen e-ink dashboard: reserve explicit rows so the load heading and
+ summary never get pushed through the card boundary. */
+body.eink-client #systemSideboardPage.dashboard-grid .side-load {
+ grid-template-rows: auto minmax(0, 1fr) auto !important;
+ align-content: stretch !important;
+ gap: 4px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-big {
+ align-self: center !important;
+ font-size: clamp(42px, 7vw, 62px) !important;
+ line-height: .9 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-summary {
+ gap: 1px !important;
+ min-height: 0 !important;
+ font-size: clamp(12px, 1.2vw, 15px) !important;
+ line-height: 1.15 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-summary span {
+ overflow: hidden !important;
+ font-size: inherit !important;
+ line-height: inherit !important;
+ text-overflow: ellipsis !important;
+ white-space: nowrap !important;
+}
+
+/* Notification feed owns its scroll area. Compact chrome leaves enough room
+ for readable notification rows without growing the one-screen canvas. */
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed {
+ gap: 5px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-title {
+ font-size: 23px !important;
+ line-height: 1.05 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-source,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-footer,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-freshness {
+ font-size: 13px !important;
+ line-height: 1.15 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-freshness {
+ padding: 2px 4px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list {
+ min-height: 0 !important;
+ gap: 5px !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+ scrollbar-width: auto;
+ touch-action: pan-y;
+ -webkit-overflow-scrolling: touch;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-item {
+ padding: 6px 7px !important;
+ border-width: 2px 2px 2px 7px !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-meta {
+ font-size: 12px !important;
+ line-height: 1.15 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-text {
+ font-size: 15px !important;
+ line-height: 1.25 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list::-webkit-scrollbar {
+ width: 12px;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list::-webkit-scrollbar-track {
+ border-left: 2px solid #000;
+ background: #fffef8;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list::-webkit-scrollbar-thumb {
+ border: 2px solid #fffef8;
+ background: #000;
+}
+
+/* Work Pulse's default copy is the e-ink readability baseline. Metadata and
+ secondary values may be quieter, but never physically smaller. */
+body.eink-client {
+ --eink-dashboard-min-font: 17px;
+}
+
+body.eink-client .side-title span,
+body.eink-client #systemSideboardPage.dashboard-grid .side-kicker,
+body.eink-client #systemSideboardPage.dashboard-grid .feed-heading,
+body.eink-client #systemSideboardPage.dashboard-grid .metric-sub,
+body.eink-client #systemSideboardPage.dashboard-grid .side-summary,
+body.eink-client #systemSideboardPage.dashboard-grid .feed-list,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-source,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-footer,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-freshness,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-meta,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-text,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-empty,
+body.eink-client .dashboard-edit-copy span,
+body.eink-client .dashboard-library-empty,
+body.eink-client .dashboard-edit-actions button,
+body.eink-client .dashboard-card-library button,
+body.eink-client .dashboard-selection-controls button,
+body.eink-client .dashboard-card-editor button {
+ font-size: var(--eink-dashboard-min-font) !important;
+ line-height: 1.2 !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-summary {
+ font-size: var(--eink-dashboard-min-font) !important;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-source,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-footer,
+body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-freshness {
+ font-size: var(--eink-dashboard-min-font) !important;
+}
+
+body.eink-client .dashboard-card-editor {
+ display: grid !important;
+ grid-template-columns: repeat(3, auto);
+ max-width: calc(100% - 12px);
+}
+
+body.eink-client .dashboard-card-editor button {
+ min-width: 60px;
+ min-height: 40px;
+ padding: 4px 7px;
+ white-space: nowrap;
+}
+
+/* Dashboard-local navigation appears only where the global header is hidden
+ (immersive fullscreen / deck window); otherwise the top tabs are the single
+ navigation and the in-panel switch is hidden. */
+body:not(.dashboard-viewer):not(.viewer-immersive):not(.deck-window) .dashboard-mode-switch {
+ display: none;
+}
+
+.dashboard-view-controls,
+.dashboard-mode-switch {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ gap: 6px;
+}
+
+.dashboard-mode-switch {
+ padding: 3px;
+ border: 1px solid var(--side-line, rgba(255,255,255,.22));
+ border-radius: 5px;
+ background: rgba(255,255,255,.06);
+}
+
+.dashboard-mode-switch button {
+ min-height: 38px;
+ padding: 5px 10px;
+ border: 1px solid transparent;
+ border-radius: 3px;
+ background: transparent;
+ color: inherit;
+ font-size: 17px;
+ font-weight: 800;
+ white-space: nowrap;
+}
+
+.dashboard-mode-switch button.active {
+ border-color: var(--side-accent, currentColor);
+ background: var(--side-accent, #fff);
+ color: #071015;
+}
+
+body.phone-client.dashboard-viewer .eink-connection-state,
+body.phone-client.viewer-immersive .eink-connection-state,
+body.phone-client.deck-window .eink-connection-state {
+ display: inline-flex;
+ flex: 0 0 auto;
+ align-items: center;
+ justify-content: center;
+ min-height: 42px;
+ padding: 4px 10px;
+ border: 2px solid currentColor;
+ border-radius: 4px;
+ color: var(--side-accent, #fff);
+ background: transparent;
+ font-size: 17px;
+ font-weight: 900;
+ line-height: 1;
+ white-space: nowrap;
+}
+
+body.phone-client .eink-connection-state.online {
+ color: #071015;
+ border-color: var(--side-accent, #fff);
+ background: var(--side-accent, #fff);
+}
+
+body.eink-client .eink-connection-state {
+ border: 3px solid #000;
+ color: #000;
+ background: #fffef8;
+}
+
+body.eink-client .eink-connection-state.online {
+ border-color: #000;
+ color: #fffef8;
+ background: #000;
+}
+
+body.eink-client .dashboard-mode-switch {
+ border: 2px solid #000;
+ border-radius: 4px;
+ background: #fffef8;
+}
+
+body.eink-client .dashboard-mode-switch button {
+ min-height: 42px;
+ border: 2px solid transparent;
+ background: #fffef8;
+ color: #000;
+}
+
+body.eink-client .dashboard-mode-switch button.active {
+ border-color: #000;
+ background: #000;
+ color: #fffef8;
+}
+
+/* The previous readability and card-editing work also applies to the regular
+ phone dashboard, not only to BOOX mode. */
+body.phone-client:not(.eink-client) {
+ --eink-dashboard-min-font: 17px;
+}
+
+body.phone-client:not(.eink-client) .side-title span,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .side-kicker,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .feed-heading,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .metric-sub,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .side-summary,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .feed-list,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-source,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-footer,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-freshness,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-feed-meta,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-feed-text,
+body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-empty,
+body.phone-client:not(.eink-client) .dashboard-edit-copy span,
+body.phone-client:not(.eink-client) .dashboard-library-empty,
+body.phone-client:not(.eink-client) .dashboard-edit-actions button,
+body.phone-client:not(.eink-client) .dashboard-card-library button,
+body.phone-client:not(.eink-client) .dashboard-selection-controls button,
+body.phone-client:not(.eink-client) .dashboard-card-editor button {
+ font-size: var(--eink-dashboard-min-font) !important;
+ line-height: 1.2 !important;
+}
+
+body.phone-client.viewport-portrait:not(.eink-client) .sideboard-top,
+body.phone-client.viewport-portrait:not(.eink-client) .quota-top {
+ grid-template-columns: minmax(0, 1fr) !important;
+}
+
+body.phone-client.viewport-portrait:not(.eink-client) .dashboard-view-controls {
+ width: 100%;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+}
+
+@media (max-width: 960px) {
+ body.ios-client.phone-client:not(.eink-client).dashboard-viewer.mode-sideboard .sideboard-shell {
+ padding-top: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
+ padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
+ padding-bottom: calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
+ padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left, 0px)));
+ }
+}
+
+/* The large load number is reserved for an actual bottleneck. Idle systems
+ get a quiet animated companion; multiple warnings rotate in the same spot. */
+.side-load-normal,
+.side-load-alert {
+ display: grid;
+ justify-items: center;
+ align-content: center;
+ gap: 5px;
+ min-height: 0;
+ text-align: center;
+}
+
+.side-load-normal[hidden],
+.side-load-alert[hidden] { display: none !important; }
+
+.side-load-normal > strong,
+.side-load-alert > strong {
+ color: var(--side-accent);
+ font-size: clamp(19px, 2.3vw, 30px);
+ line-height: 1.05;
+}
+
+.side-load-normal > span,
+.side-load-alert > span {
+ color: var(--side-muted);
+ font-size: clamp(12px, 1.4vw, 17px);
+}
+
+.side-load-alert > strong,
+.side-load-alert > span { color: var(--side-warn); }
+
+.load-pet {
+ position: relative;
+ width: clamp(72px, 8vw, 112px);
+ height: clamp(54px, 6vw, 82px);
+ border: 4px solid var(--side-accent);
+ border-radius: 42% 42% 48% 48%;
+ background: rgba(255,255,255,.05);
+ animation: load-pet-breathe 2.8s ease-in-out infinite;
+}
+
+.load-pet::before,
+.load-pet::after {
+ content: "";
+ position: absolute;
+ top: -16px;
+ width: 22px;
+ height: 22px;
+ border: 4px solid var(--side-accent);
+ background: var(--side-panel);
+ transform: rotate(45deg);
+}
+
+.load-pet::before { left: 5px; }
+.load-pet::after { right: 5px; }
+
+.load-pet span,
+.load-pet span::after {
+ position: absolute;
+ top: 22px;
+ width: 9px;
+ height: 13px;
+ border-radius: 50%;
+ background: var(--side-accent);
+ content: "";
+}
+
+.load-pet span { left: 24%; }
+.load-pet span::after { left: 250%; top: 0; }
+
+@keyframes load-pet-breathe {
+ 0%, 100% { transform: translateY(1px) scale(1); }
+ 50% { transform: translateY(-4px) scale(1.03); }
+}
+
+body.eink-client .side-load-normal > strong,
+body.eink-client .side-load-alert > strong,
+body.eink-client .side-load-normal > span,
+body.eink-client .side-load-alert > span {
+ color: #000;
+ font-size: var(--eink-dashboard-min-font) !important;
+}
+
+body.eink-client .load-pet {
+ border-color: #000;
+ background: #fffef8;
+ animation: none;
+}
+
+body.eink-client .load-pet::before,
+body.eink-client .load-pet::after {
+ border-color: #000;
+ background: #fffef8;
+}
+
+body.eink-client .load-pet span,
+body.eink-client .load-pet span::after { background: #000; }
+
diff --git a/src/PhoneMonitor.Host/wwwroot/css/50-activity.css b/src/PhoneMonitor.Host/wwwroot/css/50-activity.css
new file mode 100644
index 0000000..c6afc75
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/css/50-activity.css
@@ -0,0 +1,1143 @@
+/* Work Pulse and Windows notifications share one chronological activity card. */
+.activity-feed-card {
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: 7px;
+ overflow: hidden;
+}
+
+.activity-feed-head,
+.quota-mini-head,
+.quota-mini-value-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ min-width: 0;
+}
+
+.activity-feed-filters {
+ display: flex;
+ gap: 4px;
+ flex: 0 0 auto;
+}
+
+.activity-feed-filters button {
+ min-height: 30px;
+ padding: 3px 10px;
+ border: 1px solid var(--side-line);
+ border-radius: 2px;
+ background: transparent;
+ color: var(--side-muted);
+ font: inherit;
+ font-weight: 700;
+}
+
+.activity-feed-filters button.active {
+ border-color: var(--side-accent);
+ background: var(--side-accent);
+ color: #071015;
+}
+
+.activity-feed-list {
+ display: grid;
+ align-content: start;
+ gap: 5px;
+ min-width: 0;
+ min-height: 0;
+ margin: 0;
+ padding: 0 4px 0 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ scrollbar-gutter: stable;
+ touch-action: pan-y;
+ list-style: none;
+ scrollbar-width: thin;
+}
+
+.activity-feed-item {
+ min-width: 0;
+ padding: 6px 8px;
+ border: 1px solid var(--side-line);
+ background: rgba(7, 16, 21, .46);
+}
+
+.activity-feed-meta {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ min-width: 0;
+ color: var(--side-muted);
+ font-size: 13px;
+}
+
+.activity-feed-meta time { margin-left: auto; }
+
+.activity-source {
+ flex: 0 0 auto;
+ color: var(--side-accent);
+ font-weight: 800;
+}
+
+.activity-from {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.activity-feed-text {
+ margin-top: 2px;
+ overflow-wrap: anywhere;
+ color: var(--side-text);
+ line-height: 1.28;
+}
+
+.activity-feed-empty {
+ align-self: center;
+ padding: 12px 0;
+ color: var(--side-muted);
+ text-align: center;
+}
+
+.quota-mini-card {
+ display: grid;
+ grid-template-rows: auto auto auto auto 8px;
+ align-content: center;
+ gap: 5px;
+ overflow: hidden;
+}
+
+.quota-mini-head strong { color: var(--side-accent); }
+.quota-mini-head span,
+.quota-mini-value-row span { color: var(--side-muted); }
+
+.quota-mini-card select {
+ min-width: 0;
+ width: 100%;
+ min-height: 30px;
+ padding: 3px 7px;
+ border: 1px solid var(--side-line);
+ border-radius: 2px;
+ background: rgba(7, 16, 21, .72);
+ color: var(--side-text);
+ font: inherit;
+ font-weight: 700;
+ text-overflow: ellipsis;
+}
+
+.quota-mini-value-row strong {
+ color: var(--side-accent);
+ font-size: clamp(25px, 3vw, 40px);
+ line-height: .95;
+}
+
+.activity-feed-filter-select {
+ display: none;
+}
+
+.quota-mini-credit {
+ overflow: hidden;
+ color: var(--side-muted);
+ font-size: 17px;
+ font-weight: 700;
+ line-height: 1.2;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.quota-mini-bar {
+ height: 8px;
+ border: 1px solid var(--side-line);
+ overflow: hidden;
+}
+
+.quota-mini-bar span {
+ display: block;
+ width: 0;
+ height: 100%;
+ background: var(--side-accent);
+ transition: width .18s ease;
+}
+
+body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-card,
+body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-card {
+ padding: 9px !important;
+ border: 2px solid #000 !important;
+ background: #fffef8 !important;
+ color: #000 !important;
+}
+
+body.eink-client .activity-feed-filters button {
+ min-height: 38px;
+ padding: 3px 11px;
+ border: 2px solid #000;
+ background: #fffef8;
+ color: #000;
+ font-size: 17px;
+}
+
+body.eink-client .activity-feed-filters button.active {
+ background: #000;
+ color: #fffef8;
+}
+
+body.eink-client .activity-feed-filters {
+ display: none !important;
+}
+
+body.eink-client .activity-feed-filter-select {
+ display: block;
+ flex: 0 1 180px;
+ width: min(180px, 48%);
+ min-width: 120px;
+ min-height: 42px;
+ padding: 4px 34px 4px 10px;
+ border: 2px solid #000;
+ border-radius: 2px;
+ background: #fffef8;
+ color: #000;
+ font: inherit;
+ font-size: 17px;
+ font-weight: 800;
+}
+
+body.eink-client .activity-feed-list { scrollbar-width: auto; }
+
+body.eink-client .activity-feed-item {
+ padding: 6px 8px;
+ border: 2px solid #000;
+ background: #fffef8;
+ color: #000;
+}
+
+body.eink-client .activity-feed-meta,
+body.eink-client .activity-feed-text,
+body.eink-client .activity-feed-empty,
+body.eink-client .quota-mini-head,
+body.eink-client .quota-mini-head span,
+body.eink-client .quota-mini-value-row span,
+body.eink-client .quota-mini-card select {
+ color: #000;
+ font-size: var(--eink-dashboard-min-font) !important;
+ line-height: 1.2;
+}
+
+body.eink-client .activity-source,
+body.eink-client .quota-mini-head strong,
+body.eink-client .quota-mini-value-row strong {
+ color: #000;
+ font-weight: 900;
+}
+
+body.eink-client .activity-source {
+ padding: 1px 5px;
+ border: 2px solid #000;
+}
+
+body.eink-client .activity-feed-list::-webkit-scrollbar { width: 12px; }
+body.eink-client .activity-feed-list::-webkit-scrollbar-track {
+ border-left: 2px solid #000;
+ background: #fffef8;
+}
+body.eink-client .activity-feed-list::-webkit-scrollbar-thumb {
+ border: 2px solid #fffef8;
+ background: #000;
+}
+
+body.eink-client .quota-mini-card select {
+ min-height: 36px;
+ border: 2px solid #000;
+ background: #fffef8;
+}
+
+body.eink-client .quota-mini-value-row strong {
+ font-size: clamp(30px, 4vw, 46px) !important;
+}
+
+body.eink-client .quota-credit-row,
+body.eink-client .quota-mini-credit {
+ color: #000 !important;
+ font-size: var(--eink-dashboard-min-font) !important;
+ line-height: 1.2 !important;
+}
+
+body.eink-client .quota-mini-bar {
+ height: 10px;
+ border: 2px solid #000;
+}
+
+body.eink-client .quota-mini-bar span {
+ background: #000;
+ transition: none;
+}
+
+body.phone-client:not(.eink-client) .activity-feed-filters button,
+body.phone-client:not(.eink-client) .activity-feed-meta,
+body.phone-client:not(.eink-client) .activity-feed-text,
+body.phone-client:not(.eink-client) .activity-feed-empty,
+body.phone-client:not(.eink-client) .quota-mini-head,
+body.phone-client:not(.eink-client) .quota-mini-value-row span,
+body.phone-client:not(.eink-client) .quota-mini-card select {
+ font-size: 17px;
+}
+
+body.phone-client:not(.eink-client) .quota-credit-row,
+body.phone-client:not(.eink-client) .quota-mini-credit {
+ font-size: var(--eink-dashboard-min-font) !important;
+ line-height: 1.2 !important;
+}
+
+.energy-wave-backdrop {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ overflow: hidden;
+ pointer-events: none;
+ background: #000;
+ isolation: isolate;
+ border-radius: inherit;
+ opacity: 0;
+ transition: opacity 420ms ease;
+}
+
+.energy-wave-backdrop.is-active {
+ opacity: 1;
+}
+
+.energy-wave-canvas,
+.energy-wave-strips,
+.energy-wave-vignette {
+ position: absolute;
+ inset: 0;
+}
+
+.energy-wave-canvas {
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+}
+
+.energy-wave-strips {
+ z-index: 2;
+ display: grid;
+ grid-template-columns: repeat(24, minmax(0, 1fr));
+ mix-blend-mode: screen;
+}
+
+.energy-wave-strip {
+ min-width: 0;
+ border-left: 1px solid rgba(191, 235, 255, 0.13);
+ background:
+ linear-gradient(90deg,
+ rgba(79, 207, 255, 0.08) 0%,
+ rgba(255, 255, 255, 0.024) 28%,
+ rgba(230, 109, 255, 0.065) 67%,
+ rgba(255, 198, 94, 0.07) 100%);
+ box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.035), inset -1px 0 0 rgba(22, 93, 160, 0.06);
+}
+
+.energy-wave-strip:first-child {
+ border-left: 0;
+}
+
+.energy-wave-strip:nth-child(3n) {
+ background:
+ linear-gradient(90deg,
+ rgba(87, 229, 255, 0.11),
+ rgba(255, 255, 255, 0.018) 43%,
+ rgba(255, 87, 198, 0.09));
+}
+
+.energy-wave-strip:nth-child(4n) {
+ background:
+ linear-gradient(90deg,
+ rgba(255, 207, 112, 0.09),
+ rgba(255, 255, 255, 0.012) 46%,
+ rgba(126, 118, 255, 0.08));
+}
+
+.energy-wave-vignette {
+ z-index: 3;
+ background:
+ radial-gradient(ellipse 82% 70% at 50% 54%, transparent 25%, rgba(0, 0, 0, 0.56) 100%),
+ linear-gradient(180deg, rgba(0, 0, 0, 0.44), transparent 24%, transparent 72%, rgba(0, 0, 0, 0.56));
+}
+
+body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell {
+ --side-bg: #000;
+ --side-panel: rgba(7, 13, 21, 0.57);
+ background: #000;
+ border-color: rgba(175, 234, 255, 0.22);
+ box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.045);
+ isolation: isolate;
+}
+
+body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell::before {
+ z-index: 1;
+ opacity: 0.11;
+ mix-blend-mode: screen;
+}
+
+body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell::after {
+ z-index: 2;
+ pointer-events: none;
+ background:
+ linear-gradient(90deg, rgba(0, 1, 3, 0.52), rgba(0, 2, 5, 0.12) 48%, rgba(0, 1, 3, 0.52)),
+ radial-gradient(circle at 50% 47%, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.4) 78%);
+}
+
+body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell > :not(.energy-wave-backdrop),
+body.mode-sideboard.energy-wave-active:not(.eink-client) #systemSideboardPage.dashboard-grid [data-dashboard-key] {
+ z-index: 3;
+}
+
+body.mode-sideboard.energy-wave-active:not(.eink-client) .side-panel,
+body.mode-sideboard.energy-wave-active:not(.eink-client) .metric-card,
+body.mode-sideboard.energy-wave-active:not(.eink-client) .custom-card {
+ backdrop-filter: blur(10px) saturate(1.08);
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 16px 36px rgba(0, 0, 0, 0.28);
+}
+
+body.eink-client .energy-wave-backdrop {
+ display: none !important;
+}
+
+body.phone-client:not(.eink-client).energy-wave-active .mobile-overview-section,
+body.phone-client:not(.eink-client).energy-wave-active .mobile-detail-block,
+body.phone-client:not(.eink-client).energy-wave-active .mobile-detail-host,
+body.phone-client:not(.eink-client).energy-wave-active .mobile-detail-metrics article {
+ background: linear-gradient(145deg, rgba(14, 34, 40, .78), rgba(7, 20, 25, .72));
+ backdrop-filter: blur(10px) saturate(1.08);
+ -webkit-backdrop-filter: blur(10px) saturate(1.08);
+}
+
+body.phone-client:not(.eink-client).energy-wave-active .mobile-metric-card {
+ background: rgba(8, 24, 29, .7);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .energy-wave-backdrop {
+ transition: none;
+ }
+}
+
+.quota-codex-switcher {
+ grid-template-rows: auto auto auto;
+}
+
+.quota-codex-switch-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ min-width: 0;
+}
+
+.quota-codex-select {
+ flex: 1 1 190px;
+ min-width: 0;
+ min-height: 34px;
+ padding: 6px 10px;
+ border: 1px solid rgba(255,255,255,.2);
+ border-radius: 8px;
+ background: rgba(0,0,0,.22);
+ color: inherit;
+ font: inherit;
+ font-size: 14px;
+}
+
+body.eink-client .quota-codex-select {
+ min-height: 48px;
+ border: 3px solid #000;
+ border-radius: 4px;
+ background: #fffef8;
+ color: #000;
+ font-size: 18px;
+ font-weight: 800;
+}
+
+/* E-ink layout safety net ----------------------------------------------------
+ * BOOX / e-reader browsers span a wide range of CSS viewport sizes. Keep
+ * cards readable, but never let a long label or a dense action row widen the
+ * page beyond the paper viewport. This block stays last on purpose. */
+body.eink-client {
+ overflow-x: hidden;
+}
+
+body.eink-client *,
+body.eink-client *::before,
+body.eink-client *::after {
+ box-sizing: border-box;
+}
+
+body.eink-client header,
+body.eink-client main,
+body.eink-client .app-view,
+body.eink-client .sideboard-view,
+body.eink-client .quota-view,
+body.eink-client .sideboard-shell,
+body.eink-client .quota-shell,
+body.eink-client .sideboard-top,
+body.eink-client .quota-top,
+body.eink-client .dashboard-edit-bar,
+body.eink-client .dashboard-config-panel,
+body.eink-client .custom-sideboard-page,
+body.eink-client .custom-cards-grid,
+body.eink-client .custom-sources-manager,
+body.eink-client .quota-account-card,
+body.eink-client .quota-account-head,
+body.eink-client .quota-codex-switch-row {
+ min-width: 0;
+ max-width: 100%;
+}
+
+body.eink-client img,
+body.eink-client video,
+body.eink-client canvas,
+body.eink-client svg,
+body.eink-client input,
+body.eink-client select,
+body.eink-client textarea,
+body.eink-client button {
+ max-width: 100%;
+}
+
+body.eink-client .brand-copy,
+body.eink-client .side-title,
+body.eink-client .side-title span,
+body.eink-client .quota-title,
+body.eink-client .quota-title span,
+body.eink-client .quota-status,
+body.eink-client .quota-account-email,
+body.eink-client .quota-account-switcher,
+body.eink-client .quota-action-status,
+body.eink-client .quota-window small,
+body.eink-client .custom-source-meta,
+body.eink-client .custom-source-health,
+body.eink-client .custom-card-source,
+body.eink-client .custom-card-footer,
+body.eink-client .custom-feed-text,
+body.eink-client .custom-feed-meta {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+body.eink-client .top-controls,
+body.eink-client .utility-controls,
+body.eink-client .sideboard-top,
+body.eink-client .dashboard-view-controls,
+body.eink-client .dashboard-mode-switch,
+body.eink-client .dashboard-edit-actions,
+body.eink-client .dashboard-card-library,
+body.eink-client .dashboard-selection-controls,
+body.eink-client .custom-cards-top,
+body.eink-client .custom-manager-header,
+body.eink-client .custom-source-row,
+body.eink-client .custom-source-actions,
+body.eink-client .custom-source-form-actions,
+body.eink-client .activity-feed-head,
+body.eink-client .quota-mini-head,
+body.eink-client .quota-mini-value-row {
+ flex-wrap: wrap;
+}
+
+body.eink-client .sideboard-top {
+ align-items: flex-start;
+}
+
+body.eink-client .side-title,
+body.eink-client .dashboard-view-controls {
+ flex: 1 1 220px;
+ max-width: 100%;
+}
+
+body.eink-client .dashboard-view-controls,
+body.eink-client .dashboard-mode-switch {
+ min-width: 0;
+ max-width: 100%;
+}
+
+body.eink-client .dashboard-mode-switch {
+ flex: 1 1 180px;
+}
+
+body.eink-client .dashboard-mode-switch button {
+ flex: 1 1 88px;
+ min-width: 0;
+ white-space: normal;
+}
+
+body.eink-client .eink-connection-state {
+ max-width: 100%;
+ white-space: normal;
+ text-align: center;
+}
+
+/* The quota page used to keep a three-column desktop header and icon-sized
+ * controls. E-ink action labels now wrap and remain tappable instead. */
+body.eink-client .quota-top {
+ grid-template-columns: minmax(0, 1fr);
+ align-items: start;
+}
+
+body.eink-client .quota-left,
+body.eink-client .quota-top .dashboard-view-controls,
+body.eink-client .quota-status {
+ grid-column: 1;
+ justify-self: start;
+ width: 100%;
+}
+
+body.eink-client .quota-title {
+ flex-wrap: wrap;
+ white-space: normal;
+}
+
+body.eink-client .quota-tabs {
+ width: 100%;
+ max-width: 100%;
+}
+
+body.eink-client .quota-account-head {
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ align-items: start;
+}
+
+body.eink-client .quota-account-email {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+}
+
+body.eink-client .quota-account-switcher {
+ grid-column: 2 / -1;
+ grid-row: 2;
+ justify-self: start;
+ white-space: normal;
+}
+
+body.eink-client .quota-pill {
+ grid-column: 3;
+ grid-row: 1;
+ justify-self: end;
+}
+
+body.eink-client .quota-pair,
+body.eink-client .quota-footer {
+ grid-template-columns: minmax(0, 1fr);
+}
+
+body.eink-client .quota-footer {
+ justify-items: start;
+}
+
+body.eink-client .quota-toolbox {
+ justify-self: start;
+ display: flex;
+ flex-wrap: wrap;
+ width: 100%;
+ max-width: 100%;
+ gap: 8px;
+}
+
+body.eink-client .quota-toolbox button {
+ flex: 1 1 152px;
+ width: auto;
+ min-width: 0;
+ min-height: 48px;
+ height: auto;
+ padding: 6px 12px;
+ white-space: normal;
+ text-align: center;
+ overflow-wrap: anywhere;
+}
+
+body.eink-client .quota-codex-switch-row {
+ align-items: stretch;
+}
+
+body.eink-client .quota-codex-select {
+ flex: 1 1 100%;
+ width: 100%;
+}
+
+body.eink-client .quota-codex-switch-row .quota-toolbox {
+ flex: 1 1 100%;
+}
+
+body.eink-client.dashboard-viewer main,
+body.eink-client.viewer-immersive main,
+body.eink-client.viewer-fullscreen main {
+ height: 100dvh !important;
+ min-height: 0 !important;
+ overflow-x: hidden !important;
+ overflow-y: auto !important;
+}
+
+body.eink-client.dashboard-viewer .quota-view.active,
+body.eink-client.viewer-immersive .quota-view.active,
+body.eink-client.viewer-fullscreen .quota-view.active,
+body.eink-client.mode-quota .quota-shell {
+ height: auto !important;
+ min-height: 100dvh !important;
+ overflow: visible !important;
+}
+
+body.eink-client.mode-quota .quota-shell {
+ grid-template-rows: auto auto auto !important;
+ align-content: start;
+}
+
+body.eink-client.mode-quota .quota-grid,
+body.eink-client.mode-quota .quota-account-page,
+body.eink-client.mode-quota .quota-account-card {
+ height: auto !important;
+ min-height: 0 !important;
+ align-content: start !important;
+ overflow: visible !important;
+}
+
+body.eink-client.mode-quota .quota-account-card {
+ display: block !important;
+ grid-template-rows: auto auto auto auto auto !important;
+ min-height: max-content !important;
+ align-self: start !important;
+}
+
+body.eink-client.mode-quota .quota-account-card > * + * {
+ margin-top: 16px;
+}
+
+body.eink-client.mode-quota .quota-pair {
+ min-height: max-content !important;
+}
+
+body.eink-client.mode-quota .quota-eink-overview {
+ display: flex !important;
+ flex: 0 0 auto;
+ flex-direction: column;
+ align-self: start !important;
+ gap: 14px;
+ padding: 18px !important;
+}
+
+body.eink-client.mode-quota .quota-eink-overview > * {
+ flex: 0 0 auto;
+ min-width: 0;
+ margin: 0 !important;
+}
+
+body.eink-client.mode-quota .quota-eink-overview-head {
+ display: grid;
+ gap: 10px;
+}
+
+body.eink-client.mode-quota .quota-eink-overview-head > .quota-account-switcher {
+ grid-column: auto;
+ grid-row: auto;
+ justify-self: start;
+}
+
+body.eink-client.mode-quota .quota-eink-usage {
+ display: block !important;
+ height: auto !important;
+ min-height: 0 !important;
+ overflow: visible !important;
+}
+
+body.eink-client.mode-quota .quota-eink-usage .quota-provider-title {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 22px !important;
+}
+
+body.eink-client.mode-quota .quota-eink-usage .quota-window {
+ gap: 6px 12px;
+ padding: 14px 0;
+ border-top: 2px solid #000;
+}
+
+body.eink-client.mode-quota .quota-eink-usage .quota-provider-title + .quota-window {
+ border-top: 0;
+}
+
+body.eink-client.mode-quota .quota-eink-usage .quota-window b {
+ font-size: clamp(36px, 6vw, 48px) !important;
+}
+
+body.eink-client.mode-quota .quota-eink-credit {
+ flex-wrap: wrap;
+ padding-top: 14px;
+ border-top: 2px solid #000;
+ font-size: 20px !important;
+}
+
+body.eink-client.mode-quota .quota-eink-overview-footer {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding-top: 14px;
+ border-top: 2px solid #000;
+}
+
+body.eink-client.mode-quota .quota-eink-overview-footer .quota-toolbox {
+ width: auto;
+}
+
+body.eink-client.mode-quota .quota-eink-overview-footer .quota-toolbox button {
+ flex: 0 1 auto;
+ width: auto;
+ padding: 6px 14px;
+}
+
+body.eink-client.mode-quota .quota-eink-account-manager {
+ margin: 0 !important;
+ border: 2px solid #000;
+ background: #f2f0e8;
+}
+
+body.eink-client.mode-quota .quota-eink-account-manager > summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ list-style: none;
+ min-height: 52px;
+ padding: 12px 14px;
+ color: #000;
+ font-size: 20px;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+body.eink-client.mode-quota .quota-eink-account-manager > summary::-webkit-details-marker {
+ display: none;
+}
+
+body.eink-client.mode-quota .quota-eink-account-manager > summary::after {
+ content: "+";
+ font-size: 26px;
+ line-height: 1;
+}
+
+body.eink-client.mode-quota .quota-eink-account-manager[open] > summary {
+ border-bottom: 2px solid #000;
+}
+
+body.eink-client.mode-quota .quota-eink-account-manager[open] > summary::after {
+ content: "−";
+}
+
+body.eink-client.mode-quota .quota-eink-account-controls {
+ min-width: 0;
+ padding: 14px;
+}
+
+body.eink-client.mode-quota .quota-eink-account-controls .quota-codex-switch-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: 12px;
+}
+
+body.eink-client.mode-quota .quota-eink-account-controls .quota-toolbox {
+ display: grid !important;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ width: 100%;
+ gap: 10px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+}
+
+body.eink-client.mode-quota .quota-eink-account-controls .quota-toolbox button {
+ width: 100%;
+ min-height: 52px;
+ padding: 6px 12px;
+}
+
+body.eink-client .custom-cards-toolbar,
+body.eink-client .custom-source-actions,
+body.eink-client .custom-source-form-actions {
+ flex: 1 1 100%;
+ justify-content: flex-start;
+}
+
+body.eink-client .custom-cards-toolbar button,
+body.eink-client .custom-manager-header button,
+body.eink-client .custom-source-actions button,
+body.eink-client .custom-source-form-actions button {
+ flex: 1 1 112px;
+ min-width: 0;
+ white-space: normal;
+}
+
+/* Language is a product-level setting, not a display-only control. Keep it
+ * reachable after a phone or e-reader enters the trusted dashboard/quota UI. */
+body.phone-client.device-trusted:not(.mode-display) .utility-controls {
+ display: flex !important;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+body.phone-client.device-trusted:not(.mode-display) .utility-controls > * {
+ display: none !important;
+}
+
+body.phone-client.device-trusted:not(.mode-display) .utility-controls .locale-control {
+ display: inline-flex !important;
+ min-height: 44px;
+}
+
+body.phone-client.device-trusted:not(.mode-display) .locale-control select {
+ min-height: 44px;
+ max-width: 148px;
+}
+
+/* Persisted dashboard cards may be deliberately small. Containment lets their
+ * own controls and figures adapt to the card width instead of pushing a row
+ * outside the dashboard. */
+body.eink-client #systemSideboardPage.dashboard-grid [data-dashboard-key] {
+ container-type: inline-size;
+ container-name: eink-card;
+}
+
+@container eink-card (max-width: 280px) {
+ body.eink-client #systemSideboardPage.dashboard-grid .metric-card,
+ body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-card,
+ body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-card {
+ padding: 8px !important;
+ }
+
+ body.eink-client #systemSideboardPage.dashboard-grid .metric-value,
+ body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-value-row strong,
+ body.eink-client #systemSideboardPage.dashboard-grid .quota-window b {
+ font-size: clamp(24px, 18cqw, 36px) !important;
+ white-space: normal !important;
+ overflow-wrap: anywhere;
+ }
+
+ body.eink-client #systemSideboardPage.dashboard-grid .side-big {
+ font-size: clamp(32px, 28cqw, 56px) !important;
+ white-space: normal !important;
+ overflow-wrap: anywhere;
+ }
+
+ body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-head,
+ body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-head,
+ body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-value-row {
+ align-items: stretch;
+ }
+
+ body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-head > .feed-heading,
+ body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-head > strong {
+ flex: 1 1 100%;
+ }
+
+ body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-filter-select {
+ width: 100%;
+ max-width: none;
+ }
+}
+
+.phone-pair-rescue small {
+ font-size: 12px;
+}
+
+body.phone-client:not(.eink-client) .quota-tab,
+body.phone-client:not(.eink-client) .quota-page-button {
+ min-height: 36px;
+}
+
+body.phone-client:not(.eink-client) .quota-page-button {
+ width: 32px;
+ height: 36px;
+}
+
+body.phone-client:not(.eink-client) .quota-codex-select {
+ min-height: 40px;
+}
+
+body.phone-client:not(.eink-client) .quota-mobile-account-manager {
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, .14);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, .035);
+}
+
+body.phone-client:not(.eink-client) .quota-mobile-account-manager > summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-height: 44px;
+ padding: 9px 11px;
+ cursor: pointer;
+ list-style: none;
+ font-size: 14px;
+ font-weight: 800;
+}
+
+body.phone-client:not(.eink-client) .quota-mobile-account-manager > summary::-webkit-details-marker {
+ display: none;
+}
+
+body.phone-client:not(.eink-client) .quota-mobile-account-manager > summary::after {
+ content: "+";
+ font-size: 18px;
+ line-height: 1;
+}
+
+body.phone-client:not(.eink-client) .quota-mobile-account-manager[open] > summary {
+ border-bottom: 1px solid rgba(255, 255, 255, .12);
+}
+
+body.phone-client:not(.eink-client) .quota-mobile-account-manager[open] > summary::after {
+ content: "−";
+}
+
+body.phone-client:not(.eink-client) .quota-mobile-account-manager .quota-codex-switch-row {
+ padding: 10px;
+}
+
+body.phone-client:not(.eink-client) #quotaSummary,
+body.phone-client:not(.eink-client) #quotaUpdated,
+body.phone-client:not(.eink-client) .quota-help-block,
+body.phone-client:not(.eink-client) .quota-help-block li,
+body.phone-client:not(.eink-client) .quota-help-summary-hint,
+body.phone-client:not(.eink-client) .quota-action-status,
+body.phone-client:not(.eink-client) .quota-account-switcher,
+body.phone-client:not(.eink-client) .quota-pill,
+body.phone-client:not(.eink-client) .quota-active-badge,
+body.phone-client:not(.eink-client) .quota-window small,
+body.phone-client:not(.eink-client) .quota-time,
+body.phone-client:not(.eink-client) #quotaMiniReset {
+ font-size: 12px;
+}
+
+body.phone-client:not(.eink-client) .quota-window span {
+ font-size: 12px;
+}
+
+body.eink-client .phone-pair-rescue small,
+body.eink-client .phone-pair-rescue strong,
+body.eink-client .phone-pair-rescue > span,
+body.eink-client .phone-pair-rescue [data-pair-progress-detail],
+body.eink-client .phone-pair-rescue [data-pair-progress-label] {
+ font-size: 16px !important;
+}
+
+body.eink-client .dashboard-view-switch button,
+body.eink-client .dashboard-mode-switch button,
+body.eink-client .activity-feed-filter-select,
+body.eink-client #quotaMiniSource,
+body.eink-client .quota-tab,
+body.eink-client .quota-page-button,
+body.eink-client .quota-codex-select {
+ min-height: 44px !important;
+}
+
+body.eink-client .quota-page-button {
+ width: 44px !important;
+ height: 44px !important;
+}
+
+body.eink-client .quota-help-summary-hint,
+body.eink-client .quota-action-status,
+body.eink-client .quota-action-status span,
+body.eink-client .quota-account-switcher,
+body.eink-client .quota-account-switcher > span,
+body.eink-client .quota-pill {
+ font-size: 16px !important;
+}
+
+body.mode-quota .quota-help {
+ display: none;
+}
+
+/* Phone quota must be governed by the usable viewport, not the desktop card
+ * minimum. This matters most on iPhone landscape where Safari leaves little
+ * vertical room after the safe areas and browser chrome. */
+@media (max-width: 960px) {
+ body.phone-client:not(.eink-client).mode-quota main {
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ align-content: start;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .quota-view {
+ width: 100%;
+ max-width: none;
+ height: auto;
+ min-height: 0;
+ justify-self: stretch;
+ align-self: start;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .quota-shell {
+ min-height: 0;
+ height: auto;
+ grid-template-rows: auto auto;
+ align-content: start;
+ overflow: visible;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .quota-grid,
+ body.phone-client:not(.eink-client).mode-quota .quota-account-page,
+ body.phone-client:not(.eink-client).mode-quota .quota-account-card {
+ height: auto;
+ min-height: 0;
+ overflow: visible;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .quota-grid {
+ grid-auto-rows: max-content;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .quota-account-card {
+ display: block;
+ min-height: max-content;
+ align-self: start;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .quota-account-card > * + * {
+ margin-top: 10px;
+ }
+}
+
+@media (max-width: 960px) and (orientation: landscape) {
+ body.phone-client:not(.eink-client).mode-quota header {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 6px;
+ padding-top: calc(6px + var(--safe-area-inset-top, env(safe-area-inset-top)));
+ padding-bottom: 6px;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .brand {
+ display: none;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .top-controls {
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 6px;
+ width: 100%;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .utility-controls {
+ gap: 5px;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota .utility-controls button {
+ min-height: 36px;
+ padding-inline: 8px;
+ }
+
+ body.phone-client:not(.eink-client).mode-quota main {
+ padding-top: 8px;
+ }
+}
diff --git a/src/PhoneMonitor.Host/wwwroot/index.css b/src/PhoneMonitor.Host/wwwroot/index.css
index cf67290..d35272f 100644
--- a/src/PhoneMonitor.Host/wwwroot/index.css
+++ b/src/PhoneMonitor.Host/wwwroot/index.css
@@ -1,7873 +1,9 @@
- :root {
- color-scheme: dark;
- font-family: "Outfit", "Segoe UI", system-ui, sans-serif;
- background: #101820;
- color: #f5f7fb;
- --bg: #101820;
- --surface: #17212c;
- --surface-2: #1d2a36;
- --surface-3: #223040;
- --line: rgba(143, 209, 255, .16);
- --line-strong: rgba(143, 209, 255, .28);
- --text: #f5f7fb;
- --muted: #b9c4d0;
- --quiet: #7f91a5;
- --green: #70d98b;
- --yellow: #ffd27a;
- --blue: #8fd1ff;
- --viewer-width: 100dvw;
- --viewer-height: 100dvh;
- /* premium gradient tokens */
- --grad-accent: linear-gradient(90deg, #8fd1ff 0%, #70d98b 100%);
- --grad-warn: linear-gradient(90deg, #ffd27a 0%, #ff9d5c 100%);
- --grad-hot: linear-gradient(90deg, #ff6b6b 0%, #ffd27a 100%);
-}
-
-.host-auth-gate {
- position: fixed;
- z-index: 1000;
- inset: 0;
- display: grid;
- place-items: center;
- padding: 20px;
- background: rgba(9, 14, 21, .92);
- backdrop-filter: blur(14px);
-}
-
-.host-auth-gate[hidden] {
- display: none;
-}
-
-.host-auth-card {
- width: min(420px, 100%);
- display: grid;
- gap: 12px;
- box-sizing: border-box;
- padding: 28px;
- border: 1px solid rgba(143, 209, 255, .24);
- border-radius: 16px;
- background: #111820;
- color: #f5f7fb;
- box-shadow: 0 24px 80px rgba(0, 0, 0, .42);
-}
-
-.host-auth-card h1,
-.host-auth-card p {
- margin: 0;
-}
-
-.host-auth-card p,
-.host-auth-card small {
- color: #aebdca;
- line-height: 1.5;
-}
-
-.host-auth-kicker {
- color: #8fd1ff;
- font: 700 12px/1.2 "Share Tech Mono", monospace;
- letter-spacing: .12em;
-}
-
-.host-auth-card label {
- margin-top: 8px;
- color: #d9e5ef;
- font-size: 13px;
-}
-
-.host-auth-card input {
- width: 100%;
- box-sizing: border-box;
- padding: 12px 14px;
- border: 1px solid rgba(143, 209, 255, .28);
- border-radius: 8px;
- background: #0b1118;
- color: #fff;
- font: inherit;
-}
-
-.host-auth-card button {
- border: 0;
- cursor: pointer;
-}
-
-.host-auth-error {
- min-height: 1.5em;
- color: #ff9e9e !important;
-}
-
-/* BOOX Go Color / color E Ink mode. Enabled by the Android shell or ?eink=1. */
-body.eink-client {
- --viewer-width: 100dvw;
- --viewer-height: 100dvh;
- color: #171714;
- background: #f2f0e8;
- font-family: Georgia, "Noto Serif TC", serif;
- -webkit-font-smoothing: auto;
-}
-
-body.eink-client #displayMode,
-body.eink-client .display-view,
-body.eink-client #keepAwake,
-body.eink-client .setup-panels,
-body.eink-client header .panel,
-body.eink-client .connect-panel,
-body.eink-client .new-device-panel,
-body.eink-client .device-management-panel,
-body.eink-client .diagnostics-panel,
-body.eink-client .pair-success-banner,
-body.eink-client .driver-state {
- display: none !important;
-}
-
-/* Keep pairing usable on e-ink until the phone is trusted. */
-body.eink-client.device-trusted .phone-pair-rescue {
- display: none !important;
-}
-
-body.eink-client #einkModeToggle.active {
- background: #24241f;
- color: #f2f0e8;
- border-color: #24241f;
-}
-
-body.eink-client header {
- min-height: 68px;
- padding: 10px 18px;
- border-bottom: 3px solid #24241f;
- box-shadow: none;
-}
-
-body.eink-client main {
- min-height: 0;
- height: auto;
- max-height: none;
- padding: 0 0 28px;
- overflow-x: hidden;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
-}
-
-body.eink-client header,
-body.eink-client main {
- background: #f2f0e8;
-}
-
-body.eink-client .mode-button,
-body.eink-client .quota-tab,
-body.eink-client .skin-picker button,
-body.eink-client button {
- min-height: 44px;
- border: 2px solid #24241f;
- border-radius: 2px;
- color: #171714;
- background: #f2f0e8;
- box-shadow: none;
- transition: none;
-}
-
-body.eink-client .mode-button.active,
-body.eink-client .quota-tab.active,
-body.eink-client .skin-picker button.active {
- color: #fff;
- background: #24241f;
- border-color: #24241f;
-}
-
-body.eink-client .sideboard-view,
-body.eink-client .quota-view {
- width: 100%;
- max-width: none;
- align-self: stretch;
-}
-
-body.eink-client .sideboard-shell,
-body.eink-client .quota-shell {
- min-height: 0;
- height: auto;
- padding: 18px;
- color: #171714;
- background: #f2f0e8;
- border: 0;
- border-radius: 0;
- box-shadow: none;
- overflow: visible;
-}
-
-body.eink-client .sideboard-shell::before,
-body.eink-client .sideboard-shell::after {
- display: none;
-}
-
-/* Always paper stack on e-ink — never the desktop 3-column board. */
-body.eink-client .sideboard-shell {
- grid-template-columns: 1fr !important;
- grid-template-rows: auto !important;
- grid-auto-rows: max-content !important;
- align-content: start;
- gap: 14px;
-}
-
-body.eink-client .sideboard-top {
- padding-bottom: 10px;
- border-bottom: 3px solid #24241f;
-}
-
-body.eink-client .side-panel,
-body.eink-client .metric-card,
-body.eink-client .quota-account-card,
-body.eink-client .quota-local-card {
- color: #171714;
- background: #f8f7f1;
- border: 2px solid #3b3b34;
- border-radius: 0;
- box-shadow: none;
-}
-
-body.eink-client .side-title span,
-body.eink-client .side-kicker,
-body.eink-client .metric-sub,
-body.eink-client .quota-title span,
-body.eink-client .quota-status,
-body.eink-client .quota-provider-title,
-body.eink-client .quota-window span,
-body.eink-client .quota-window small,
-body.eink-client .quota-time,
-body.eink-client .quota-action-status,
-body.eink-client .quota-account-switcher {
- color: #515148;
-}
-
-body.eink-client .side-big,
-body.eink-client .metric-value,
-body.eink-client .quota-window b,
-body.eink-client .quota-account-email,
-body.eink-client .quota-credit-row {
- color: #171714;
- font-weight: 800;
-}
-
-body.eink-client .brand-copy strong,
-body.eink-client .brand-copy span,
-body.eink-client .sideboard-shell .side-summary span,
-body.eink-client .sideboard-shell .feed-list li,
-body.eink-client .sideboard-shell .feed-list span,
-body.eink-client .sideboard-shell .metric-sub,
-body.eink-client .sideboard-shell .side-title span {
- color: #3d3d36 !important;
- text-shadow: none !important;
-}
-
-body.eink-client .brand-copy strong,
-body.eink-client .sideboard-shell .side-big,
-body.eink-client .sideboard-shell .metric-value {
- color: #171714 !important;
-}
-
-body.eink-client .sideboard-shell .feed-heading {
- color: #315b50 !important;
-}
-
-body.eink-client .mini-bar,
-body.eink-client .quota-bar {
- height: 10px;
- border: 1px solid #3b3b34;
- border-radius: 0;
- background: #d8d5ca;
-}
-
-body.eink-client .mini-bar span,
-body.eink-client .quota-bar i,
-body.eink-client .quota-dot.active {
- border-radius: 0;
- background: #315b50;
-}
-
-body.eink-client .quota-shell {
- /* Keep dark-theme CSS vars from overwriting light e-ink ink. */
- --quota-accent: #171714;
- --quota-blue: #24241f;
- --quota-ink: #171714;
- --quota-muted: #3d3d36;
- --quota-panel: #f8f7f1;
- grid-template-rows: auto auto minmax(0, 1fr);
- gap: 14px;
- overflow-x: hidden;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
-}
-
-body.eink-client .quota-title strong {
- font-size: clamp(25px, 3.2vw, 38px);
- color: #171714;
-}
-
-body.eink-client .quota-help {
- display: block;
-}
-
-body.eink-client .quota-help:empty {
- display: none;
-}
-
-/* Help / setup copy must be high-contrast on cream paper; dark glass styles are unreadable. */
-body.eink-client .quota-help-block {
- border: 3px solid #24241f;
- border-radius: 0;
- background: #fffef8;
- color: #171714;
- font-size: clamp(15px, 2.1vw, 18px);
- line-height: 1.5;
- font-weight: 600;
- box-shadow: none;
-}
-
-body.eink-client .quota-help-summary {
- padding: 12px 14px;
- min-height: 48px;
- color: #171714;
- font-size: clamp(16px, 2.2vw, 20px);
- font-weight: 800;
-}
-
-body.eink-client .quota-help-summary-hint {
- border: 2px solid #24241f;
- border-radius: 0;
- background: #f2f0e8;
- color: #171714;
- font-size: 12px;
- font-weight: 800;
-}
-
-body.eink-client .quota-help-body {
- padding: 0 14px 14px;
- border-top: 2px solid #24241f;
-}
-
-body.eink-client .quota-help-block ol {
- margin: 10px 0 0;
- padding-left: 1.35em;
-}
-
-body.eink-client .quota-help-block li {
- color: #171714;
- font-weight: 600;
-}
-
-body.eink-client .quota-help-block li + li {
- margin-top: 8px;
-}
-
-body.eink-client .quota-setup-card {
- gap: 14px;
- padding: 16px 18px;
- border: 3px solid #24241f;
- background: #fffef8;
-}
-
-body.eink-client .quota-setup-title {
- color: #171714;
- font-size: clamp(18px, 2.6vw, 24px);
- font-weight: 800;
-}
-
-body.eink-client .quota-setup-list {
- color: #24241f;
- font-size: clamp(15px, 2.1vw, 18px);
- font-weight: 600;
- line-height: 1.5;
-}
-
-body.eink-client .quota-setup-list b {
- color: #171714;
- font-weight: 800;
- text-decoration: underline;
- text-underline-offset: 2px;
-}
-
-body.eink-client .quota-setup-card .quota-footer {
- margin-top: 4px;
- padding-top: 12px;
- border-top: 2px solid #24241f;
-}
-
-body.eink-client .quota-setup-card .quota-time {
- color: #171714 !important;
- font-size: 14px;
- font-weight: 800;
-}
-
-body.eink-client .quota-account-card {
- gap: 16px;
- padding: 18px;
-}
-
-body.eink-client .quota-window b {
- font-size: clamp(18px, 2.4vw, 28px);
-}
-
-body.eink-client .quota-toolbox {
- padding: 0;
- border: 0;
- border-radius: 0;
- background: transparent;
-}
-
-body.eink-client .quota-toolbox button,
-body.eink-client .quota-page-button {
- width: 48px;
- height: 48px;
- min-height: 48px;
- border: 3px solid #24241f;
- border-radius: 2px;
- color: #171714;
- background: #f2f0e8;
- font-size: 20px;
- font-weight: 800;
-}
-
-@media (orientation: portrait), (max-width: 760px) {
- body.eink-client .quota-pair {
- grid-template-columns: 1fr;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- body.eink-client *,
- body.eink-client *::before,
- body.eink-client *::after {
- animation: none !important;
- transition: none !important;
- scroll-behavior: auto !important;
- }
-}
-
- *,
- *::before,
- *::after {
- box-sizing: border-box;
- }
-
- body {
- margin: 0;
- min-height: 100vh;
- display: grid;
- grid-template-rows: auto 1fr;
- overflow-x: hidden;
- background:
- radial-gradient(circle at 18% 0%, rgba(112, 217, 139, .08), transparent 32rem),
- linear-gradient(180deg, #101820, #0d131a);
- -webkit-user-select: none;
- user-select: none;
- }
-
- header {
- padding: calc(14px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(18px + var(--safe-area-inset-right, env(safe-area-inset-right))) 14px calc(18px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- background: rgba(15, 23, 32, .82);
- backdrop-filter: blur(20px) saturate(140%);
- -webkit-backdrop-filter: blur(20px) saturate(140%);
- border-bottom: 1px solid rgba(143, 209, 255, .12);
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 14px 18px;
- align-items: start;
- box-shadow: 0 1px 0 rgba(143, 209, 255, .08), 0 18px 48px rgba(0, 0, 0, .32);
- }
-
- main {
- display: grid;
- padding: 16px;
- padding-left: calc(16px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- padding-right: calc(16px + var(--safe-area-inset-right, env(safe-area-inset-right)));
- padding-bottom: calc(16px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
- min-height: 0;
- }
-
- .app-view {
- display: none;
- min-height: 0;
- }
-
- .app-view.active {
- display: grid;
- }
-
- .display-view {
- place-items: center;
- position: relative;
- }
-
- .display-toolbar {
- position: absolute;
- z-index: 12;
- top: 10px;
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- align-items: center;
- gap: 8px;
- max-width: calc(100% - 20px);
- padding: 7px;
- border: 1px solid rgba(143, 209, 255, .24);
- border-radius: 12px;
- background: rgba(8, 14, 20, .84);
- box-shadow: 0 8px 28px rgba(0, 0, 0, .38);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- }
-
- .display-toolbar[hidden] {
- display: none !important;
- }
-
- /* Collapsed: only a translucent gear. Expanded: source select + keyboard. */
- .display-toolbar.is-collapsed {
- gap: 0;
- padding: 3px;
- background: rgba(8, 14, 20, .28);
- border-color: rgba(143, 209, 255, .12);
- box-shadow: 0 4px 14px rgba(0, 0, 0, .18);
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
- }
-
- .display-toolbar.is-collapsed .display-toolbar-extras {
- display: none !important;
- }
-
- .display-toolbar-toggle {
- flex: 0 0 auto;
- display: inline-grid;
- place-items: center;
- width: 36px;
- height: 36px;
- min-height: 36px;
- min-width: 36px;
- padding: 0;
- border-radius: 999px;
- border: 1px solid rgba(143, 209, 255, .22);
- background: rgba(19, 29, 39, .42);
- color: rgba(232, 241, 248, .78);
- font-size: 18px;
- line-height: 1;
- cursor: pointer;
- transition: background .18s ease, border-color .18s ease, color .18s ease, opacity .18s ease;
- }
-
- .display-toolbar.is-collapsed .display-toolbar-toggle {
- width: 32px;
- height: 32px;
- min-width: 32px;
- min-height: 32px;
- background: rgba(19, 29, 39, .22);
- border-color: rgba(143, 209, 255, .14);
- color: rgba(232, 241, 248, .55);
- opacity: .72;
- }
-
- .display-toolbar.is-collapsed .display-toolbar-toggle:hover,
- .display-toolbar.is-collapsed .display-toolbar-toggle:active {
- opacity: 1;
- color: rgba(232, 241, 248, .92);
- background: rgba(19, 29, 39, .45);
- }
-
- .display-toolbar-gear {
- display: block;
- transform: translateY(-0.5px);
- }
-
- .display-toolbar-toggle[aria-expanded="true"] {
- color: #071017;
- background: var(--green);
- border-color: var(--green);
- opacity: 1;
- }
-
- .display-toolbar-extras {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
- }
-
- .display-source-control {
- display: flex;
- align-items: center;
- gap: 7px;
- min-width: 0;
- color: var(--muted);
- font-size: 12px;
- font-weight: 800;
- }
-
- .display-source-control select {
- width: min(50vw, 360px);
- min-width: 150px;
- min-height: 38px;
- color: var(--text);
- background: rgba(19, 29, 39, .96);
- border-color: rgba(143, 209, 255, .25);
- }
-
- .display-source-kind {
- flex: 0 0 auto;
- padding: 5px 8px;
- border-radius: 999px;
- color: var(--green);
- background: rgba(112, 217, 139, .1);
- font-size: 11px;
- font-weight: 900;
- white-space: nowrap;
- }
-
- body.physical-display-source .display-source-kind {
- color: var(--yellow);
- background: rgba(255, 210, 122, .12);
- }
-
- .remote-keyboard-button {
- flex: 0 0 auto;
- min-height: 38px;
- white-space: nowrap;
- }
-
- .remote-keyboard-button[aria-pressed="true"] {
- color: #071017;
- background: var(--green);
- border-color: var(--green);
- }
-
- .remote-keyboard-input {
- position: fixed;
- z-index: -1;
- left: 50%;
- bottom: 0;
- width: 2px;
- height: 2px;
- min-height: 0;
- padding: 0;
- opacity: .01;
- border: 0;
- resize: none;
- font-size: 16px;
- }
-
- body.viewer-fullscreen.mode-display .display-toolbar {
- position: fixed;
- top: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top)));
- }
-
- .screen-media {
- width: min(100%, 1280px);
- aspect-ratio: 16 / 9;
- object-fit: contain;
- background: #05070a;
- border: 1px solid rgba(143, 209, 255, .14);
- border-radius: 8px;
- touch-action: none;
- user-select: none;
- -webkit-touch-callout: none;
- -webkit-user-drag: none;
- box-shadow: 0 0 0 1px rgba(0, 0, 0, .3), 0 12px 40px rgba(0, 0, 0, .5);
- transition: box-shadow 0.3s ease;
- }
-
- .screen-media:hover {
- box-shadow: 0 0 0 1px rgba(143, 209, 255, .2), 0 16px 48px rgba(0, 0, 0, .6);
- }
-
- .screen-media[hidden] {
- display: none !important;
- }
-
- @media (max-width: 620px) {
- .display-toolbar {
- top: 6px;
- gap: 5px;
- padding: 5px;
- }
-
- .display-source-control > span,
- .display-source-kind {
- display: none;
- }
-
- .display-source-control select {
- width: min(62vw, 300px);
- min-width: 0;
- }
-
- .remote-keyboard-button {
- padding-inline: 10px;
- }
- }
-
- body.viewer-fullscreen {
- background: #000;
- }
-
- body.viewer-fullscreen.mode-display {
- grid-template-rows: 1fr;
- width: var(--viewer-width);
- height: var(--viewer-height);
- overflow: hidden;
- position: fixed;
- inset: 0;
- }
-
- body.viewer-fullscreen > header {
- display: none;
- }
-
- body.dashboard-viewer,
- body.viewer-immersive {
- background: #000;
- grid-template-rows: 1fr;
- width: 100dvw;
- height: 100dvh;
- min-height: 100dvh;
- overflow: hidden;
- position: fixed;
- inset: 0;
- }
-
- body.eink-client.dashboard-viewer,
- body.eink-client.viewer-immersive {
- background: #f2f0e8;
- }
-
- body.dashboard-viewer > header,
- body.viewer-immersive > header {
- display: none !important;
- }
-
- body.deck-window {
- grid-template-rows: 1fr;
- width: 100vw;
- height: 100vh;
- min-height: 100vh;
- overflow: hidden;
- background: #000;
- }
-
- body.deck-window > header,
- body.deck-window .exit-viewer {
- display: none !important;
- }
-
- body.deck-window main {
- width: 100vw;
- height: 100vh;
- min-height: 100vh;
- padding: 0;
- overflow: hidden;
- background: #000;
- }
-
- body.deck-window .app-view.active {
- width: 100%;
- height: 100%;
- max-width: none;
- justify-self: stretch;
- align-self: stretch;
- }
-
- body.dashboard-viewer main,
- body.viewer-immersive main {
- padding: 0;
- background: #000;
- width: 100dvw;
- height: 100dvh;
- min-height: 100dvh;
- max-height: 100dvh;
- overflow-y: auto;
- overflow-x: hidden;
- }
-
- body.eink-client.dashboard-viewer main,
- body.eink-client.viewer-immersive main {
- background: #f2f0e8;
- }
-
- body.dashboard-viewer .app-view.active,
- body.viewer-immersive .app-view.active {
- width: 100%;
- height: 100%;
- max-width: none;
- min-height: 100%;
- justify-self: stretch;
- align-self: stretch;
- }
-
- body.viewer-fullscreen.mode-display main {
- padding: 0;
- background: #000;
- width: var(--viewer-width);
- height: var(--viewer-height);
- overflow: hidden;
- }
-
- body.viewer-fullscreen.mode-display .screen-media {
- position: fixed;
- left: 50%;
- top: 50%;
- /* Letterbox instead of crop/stretch; keep full viewport box (safe-area is black margin). */
- width: var(--viewer-width);
- height: var(--viewer-height);
- max-width: none;
- aspect-ratio: auto;
- object-fit: contain;
- border: 0;
- border-radius: 0;
- transform: translate(-50%, -50%);
- }
-
- body.viewer-fullscreen.mode-display.rotate-90 .screen-media,
- body.viewer-fullscreen.mode-display.rotate-270 .screen-media {
- width: var(--viewer-height);
- height: var(--viewer-width);
- }
-
- .exit-viewer {
- position: fixed;
- right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right)));
- bottom: calc(10px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
- z-index: 10;
- display: none;
- min-width: 42px;
- min-height: 42px;
- padding: 0;
- border-radius: 999px;
- background: rgba(10, 14, 18, 0.66);
- border-color: rgba(255, 255, 255, 0.28);
- backdrop-filter: blur(8px);
- }
-
- body.viewer-fullscreen .exit-viewer,
- body.dashboard-viewer .exit-viewer {
- display: inline-grid;
- place-items: center;
- }
-
- body.rotate-90 .screen-media {
- transform: rotate(90deg);
- }
-
- body.viewer-fullscreen.mode-display.rotate-90 .screen-media {
- transform: translate(-50%, -50%) rotate(90deg);
- }
-
- body.rotate-180 .screen-media {
- transform: rotate(180deg);
- }
-
- body.viewer-fullscreen.mode-display.rotate-180 .screen-media {
- transform: translate(-50%, -50%) rotate(180deg);
- }
-
- body.rotate-270 .screen-media {
- transform: rotate(270deg);
- }
-
- body.viewer-fullscreen.mode-display.rotate-270 .screen-media {
- transform: translate(-50%, -50%) rotate(270deg);
- }
-
- body.viewer-fullscreen.mode-sideboard,
- body.viewer-fullscreen.mode-quota,
- body.dashboard-viewer.mode-sideboard,
- body.dashboard-viewer.mode-quota {
- grid-template-rows: 1fr;
- min-height: var(--viewer-height);
- }
-
- body.viewer-fullscreen.mode-sideboard main,
- body.viewer-fullscreen.mode-quota main,
- body.dashboard-viewer.mode-sideboard main,
- body.dashboard-viewer.mode-quota main,
- body.deck-window.mode-sideboard main,
- body.deck-window.mode-quota main {
- padding: 0;
- background: #000;
- width: 100%;
- min-height: var(--viewer-height);
- overflow-y: auto;
- overflow-x: hidden;
- }
-
- .brand {
- order: 1;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
- }
-
- .brand-mark {
- display: inline-grid;
- place-items: center;
- width: 38px;
- height: 38px;
- border: 1px solid rgba(112, 217, 139, .52);
- border-radius: 8px;
- background: #0d131a;
- color: #70d98b;
- font-family: "Share Tech Mono", Consolas, monospace;
- font-size: 19px;
- font-weight: 800;
- line-height: 1;
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08), 0 0 12px rgba(112, 217, 139, .18);
- transition: box-shadow 0.3s ease;
- }
-
- .brand-mark:hover {
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 0 24px rgba(112, 217, 139, .36);
- }
-
- .brand-copy {
- display: grid;
- gap: 2px;
- min-width: 0;
- }
-
- .brand-copy strong {
- color: var(--text);
- font-size: 19px;
- line-height: 1.05;
- }
-
- .brand-copy span {
- color: var(--quiet);
- font-size: 12px;
- line-height: 1.25;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .host-version-label {
- font-style: normal;
- font-weight: 600;
- opacity: 0.75;
- margin-left: 0.35em;
- }
-
- .product-update-status {
- max-width: 210px;
- color: var(--quiet);
- font-size: 13px;
- line-height: 1.3;
- }
-
- .controls,
- .view-switcher,
- .utility-controls {
- display: flex;
- gap: 8px;
- align-items: center;
- flex-wrap: wrap;
- min-width: 0;
- }
-
- .top-controls {
- order: 2;
- display: grid;
- grid-template-columns: auto auto;
- gap: 10px;
- align-items: center;
- justify-content: flex-end;
- }
-
- .view-switcher {
- flex-wrap: nowrap;
- padding: 4px;
- border: 1px solid rgba(255, 255, 255, .08);
- border-radius: 8px;
- background: rgba(9, 14, 20, .36);
- }
-
- .view-switcher .mode-button {
- min-height: 34px;
- border: 0;
- background: transparent;
- color: var(--muted);
- }
-
- .utility-controls {
- justify-content: flex-end;
- min-width: 0;
- }
-
- .locale-control {
- display: inline-flex;
- align-items: center;
- }
-
- .locale-control select {
- min-height: 34px;
- max-width: 136px;
- padding: 0 8px;
- border: 1px solid rgba(143, 209, 255, .22);
- border-radius: 7px;
- background: rgba(9, 14, 20, .55);
- color: var(--text);
- font: inherit;
- font-size: 12px;
- }
-
- .sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border: 0;
- }
-
- #displaySettingsToggle {
- display: none;
- }
-
- .display-empty-state {
- min-height: min(52dvh, 430px);
- width: min(100%, 720px);
- box-sizing: border-box;
- display: grid;
- place-content: center;
- justify-items: center;
- gap: 10px;
- padding: 32px;
- border: 1px dashed rgba(143, 209, 255, .24);
- border-radius: 14px;
- background: linear-gradient(145deg, rgba(18, 29, 39, .88), rgba(8, 13, 19, .92));
- color: var(--muted);
- text-align: center;
- }
-
- .display-empty-state[hidden] { display: none; }
- .display-empty-state strong { color: var(--text); font-size: 22px; }
- .display-empty-state > span:not(.display-empty-icon) { max-width: 440px; line-height: 1.5; }
- .display-empty-icon { color: var(--blue); font-size: 42px; line-height: 1; }
- .display-empty-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 6px; }
- .display-empty-state button { min-width: 170px; background: var(--blue); color: #071017; font-weight: 800; }
- .display-empty-state button.secondary { background: transparent; color: var(--text); border-color: var(--border); }
- .display-empty-state button:disabled { cursor: wait; opacity: .65; }
- .display-install-detail { min-height: 1.4em; max-width: 520px; color: var(--muted); line-height: 1.45; }
- body.display-unavailable .display-view .screen-media { display: none !important; }
- body.display-unavailable .driver-state { display: none; }
-
- .mode-button.active {
- background: var(--text);
- color: var(--surface);
- border-color: var(--text);
- box-shadow: 0 0 10px rgba(143, 209, 255, .22);
- }
-
- .view-switcher .mode-button {
- transition: background 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
- }
-
- .status {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- color: var(--muted);
- font-size: 14px;
- min-height: 34px;
- padding: 0 10px;
- border: 1px solid rgba(255, 255, 255, .08);
- border-radius: 8px;
- background: rgba(255, 255, 255, .035);
- }
-
- .dot {
- width: 10px;
- height: 10px;
- border-radius: 50%;
- background: #d64545;
- flex-shrink: 0;
- }
-
- .dot.online {
- background: #27ae60;
- box-shadow: 0 0 0 0 rgba(39, 174, 96, 0.5);
- animation: dot-pulse 2.4s ease-out infinite;
- }
-
- @keyframes dot-pulse {
- 0% { box-shadow: 0 0 0 0 rgba(39, 174, 96, 0.55); }
- 70% { box-shadow: 0 0 0 7px rgba(39, 174, 96, 0); }
- 100% { box-shadow: 0 0 0 0 rgba(39, 174, 96, 0); }
- }
-
- .panel {
- order: 4;
- grid-column: 1 / -1;
- border-top: 1px solid rgba(255, 255, 255, .07);
- padding-top: 10px;
- }
-
- .panel summary {
- cursor: pointer;
- color: #dce7f4;
- font-size: 14px;
- margin-bottom: 8px;
- }
-
- /* Setup-time controls (display / stream / resolution) collapse into a compact
- row so the everyday top bar stays slim and the actual view can breathe. */
- .setup-panels {
- order: 6;
- grid-column: 1 / -1;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- border-top: 1px solid rgba(255, 255, 255, .07);
- padding-top: 10px;
- }
-
- .setup-panels .panel {
- order: 0;
- grid-column: auto;
- border-top: 0;
- padding-top: 0;
- flex: 0 1 auto;
- border: 1px solid rgba(255, 255, 255, .08);
- border-radius: 8px;
- background: rgba(9, 14, 20, .36);
- }
-
- .setup-panels .panel:not([open]) {
- padding: 0;
- }
-
- .setup-panels .panel summary {
- margin: 0;
- padding: 7px 12px;
- list-style: none;
- font-size: 13px;
- color: var(--muted);
- user-select: none;
- }
-
- .setup-panels .panel summary::-webkit-details-marker { display: none; }
- .setup-panels .panel summary::before { content: "⚙ "; opacity: .6; }
-
- .setup-panels .panel[open] {
- flex-basis: 100%;
- background: rgba(8, 13, 19, .5);
- }
-
- .setup-panels .panel[open] summary {
- color: #dce7f4;
- border-bottom: 1px solid rgba(255, 255, 255, .07);
- margin-bottom: 8px;
- }
-
- .setup-panels .panel .controls {
- padding: 0 12px 12px;
- }
-
- .field {
- display: inline-grid;
- gap: 4px;
- font-size: 12px;
- color: var(--quiet);
- }
-
- .device-management-panel {
- order: 3;
- grid-column: 1 / -1;
- }
-
- .new-device-panel,
- .diagnostics-panel {
- order: 4;
- grid-column: 1 / -1;
- overflow: hidden;
- border: 1px solid var(--line);
- border-radius: 8px;
- background: rgba(8, 13, 19, .28);
- }
-
- .diagnostics-panel {
- order: 7;
- }
-
- .new-device-panel[hidden],
- .diagnostics-panel[hidden] {
- display: none;
- }
-
- .new-device-panel > summary,
- .diagnostics-panel > .diagnostics-toggle {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 2px 12px;
- align-items: center;
- width: 100%;
- padding: 11px 13px;
- border: 0;
- background: transparent;
- color: inherit;
- cursor: pointer;
- list-style: none;
- text-align: left;
- user-select: none;
- }
-
- .new-device-panel > summary::-webkit-details-marker,
- .diagnostics-panel > .diagnostics-toggle::-webkit-details-marker {
- display: none;
- }
-
- .new-device-panel > summary span,
- .diagnostics-panel > .diagnostics-toggle span {
- color: #e8f2ff;
- font-size: 14px;
- font-weight: 800;
- }
-
- .new-device-panel > summary small,
- .diagnostics-panel > .diagnostics-toggle small {
- grid-column: 1;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.35;
- }
-
- .new-device-panel > summary::after,
- .diagnostics-panel > .diagnostics-toggle::after {
- grid-column: 2;
- grid-row: 1 / span 2;
- color: var(--muted);
- content: "+";
- font-size: 18px;
- line-height: 1;
- }
-
- .new-device-panel[open] > summary::after,
- .diagnostics-panel.is-open > .diagnostics-toggle::after {
- content: "−";
- }
-
- .new-device-panel[open] > summary,
- .diagnostics-panel.is-open > .diagnostics-toggle {
- border-bottom: 1px solid rgba(255,255,255,.08);
- }
-
- .new-device-panel-content,
- .diagnostics-panel-content {
- padding: 12px;
- }
-
- .new-device-panel .connect-panel {
- order: initial;
- grid-column: auto;
- padding: 0;
- border: 0;
- border-radius: 0;
- background: transparent;
- }
-
- .diagnostics-panel-content {
- display: grid;
- gap: 10px;
- }
-
- .diagnostics-panel.is-open {
- display: flex;
- flex-direction: column;
- height: 260px;
- height: min(34dvh, 260px);
- }
-
- .diagnostics-panel.is-open .diagnostics-panel-content {
- display: flex;
- flex: 1 1 auto;
- flex-direction: column;
- min-height: 0;
- overflow: hidden;
- }
-
- .diagnostics-panel-content > p {
- margin: 0;
- color: var(--muted);
- font-size: 13px;
- line-height: 1.45;
- }
-
- .diagnostics-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }
-
- .diagnostics-actions button,
- .diagnostics-actions a {
- min-height: 32px;
- padding: 6px 10px;
- font-size: 12px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- text-decoration: none;
- }
-
- .diagnostics-actions a {
- border: 1px solid var(--line);
- color: var(--text);
- background: var(--panel);
- }
-
-html[data-device-preview],
-html[data-device-preview] body {
- scrollbar-width: none;
-}
-
-html[data-device-preview]::-webkit-scrollbar,
-html[data-device-preview] body::-webkit-scrollbar {
- width: 0;
- height: 0;
-}
-
- .diagnostics-list {
- display: grid;
- gap: 7px;
- max-height: 340px;
- overflow: auto;
- padding-right: 2px;
- }
-
- .diagnostics-panel.is-open .diagnostics-list {
- flex: 1 1 auto;
- min-height: 0;
- max-height: none;
- }
-
- .diagnostics-entry,
- .diagnostics-empty,
- .diagnostics-storage-error {
- display: grid;
- gap: 3px;
- padding: 8px 9px;
- border: 1px solid rgba(255,255,255,.09);
- border-radius: 6px;
- background: rgba(10,16,24,.28);
- color: #d7e1eb;
- font-size: 12px;
- line-height: 1.35;
- overflow-wrap: anywhere;
- }
-
- .diagnostics-entry.is-warning,
- .diagnostics-storage-error {
- border-color: rgba(255, 210, 122, .42);
- }
-
- .diagnostics-entry.is-error {
- border-color: rgba(255, 133, 133, .5);
- background: rgba(92, 28, 34, .24);
- }
-
- .diagnostics-entry strong {
- color: #f4f8fc;
- font-size: 12px;
- }
-
- .diagnostics-entry-meta,
- .diagnostics-entry-subject,
- .diagnostics-entry-details,
- .diagnostics-empty,
- .diagnostics-storage-error {
- color: #b9c4d0;
- }
-
- .connect-panel {
- order: initial;
- grid-column: auto;
- display: grid;
- grid-template-columns: minmax(230px, 280px) minmax(0, 1fr);
- gap: 18px;
- align-items: start;
- padding: 16px;
- background:
- linear-gradient(135deg, rgba(255,255,255,.06), rgba(255,255,255,.018)),
- rgba(8, 13, 19, .28);
- border: 1px solid var(--line);
- border-radius: 8px;
- }
-
- .connect-qr {
- display: grid;
- grid-template-rows: auto auto;
- gap: 10px;
- align-items: start;
- align-content: start;
- justify-items: center;
- min-width: 0;
- padding: 12px;
- border: 1px solid rgba(255,255,255,.08);
- border-radius: 8px;
- background: rgba(8, 12, 18, .36);
- }
-
- .connect-qr span {
- color: var(--muted);
- font-size: 13px;
- line-height: 1.35;
- text-align: center;
- }
-
- .connect-panel img {
- width: 100%;
- max-width: 252px;
- aspect-ratio: 1;
- background: #fff;
- border-radius: 7px;
- padding: 10px;
- box-sizing: border-box;
- align-self: start;
- }
-
- .connect-copy {
- display: grid;
- gap: 14px;
- min-width: 0;
- color: var(--muted);
- font-size: 13px;
- }
-
- .connect-title {
- display: grid;
- gap: 3px;
- }
-
- .connect-title strong {
- color: var(--text);
- font-size: 22px;
- line-height: 1.05;
- }
-
- .connect-title span {
- color: var(--muted);
- line-height: 1.35;
- }
-
- .connect-actions {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
- gap: 8px;
- align-items: center;
- }
-
- .connect-actions a,
- .connect-actions button {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- min-height: 40px;
- padding: 9px 13px;
- border-radius: 6px;
- border: 1px solid #3a4a5f;
- background: var(--surface-3);
- color: var(--text);
- text-decoration: none;
- white-space: nowrap;
- font-weight: 600;
- transition: background 0.18s ease, border-color 0.18s ease, transform 0.15s ease, box-shadow 0.18s ease;
- }
-
- .connect-actions a:hover,
- .connect-actions button:hover {
- background: var(--surface-2);
- border-color: rgba(143, 209, 255, .4);
- transform: translateY(-1px);
- box-shadow: 0 4px 14px rgba(0, 0, 0, .25);
- }
-
- .connect-actions [hidden],
- .connect-link-list [hidden] {
- display: none;
- }
-
- .certificate-install-help {
- margin: 0;
- color: var(--muted);
- font-size: 13px;
- line-height: 1.45;
- }
-
- .certificate-install-help[hidden] { display: none; }
-
- .connect-actions .primary-action {
- background: var(--text);
- color: var(--surface);
- border-color: var(--text);
- font-weight: 750;
- }
-
- .connect-actions .primary-action:hover {
- background: #ffffff;
- border-color: #ffffff;
- box-shadow: 0 0 18px rgba(245, 247, 251, .28), 0 4px 14px rgba(0, 0, 0, .25);
- }
-
- .device-connection-code {
- display: grid;
- gap: 10px;
- padding: 13px;
- border: 1px solid rgba(141, 230, 166, .34);
- border-radius: 8px;
- background: rgba(47, 207, 114, .06);
- }
-
- .device-connection-code[hidden] { display: none; }
-
- .device-connection-code strong {
- color: #dff8e6;
- font-size: 15px;
- }
-
- .device-connection-code p,
- .device-connection-code small {
- margin: 3px 0 0;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.45;
- }
-
- .device-connection-code p code {
- color: #dff8e6;
- font-family: var(--mono);
- font-size: 1em;
- user-select: all;
- }
-
- .device-connection-code-value {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- align-items: center;
- }
-
- .device-connection-code output {
- min-width: 184px;
- min-height: 1.2em;
- color: #ffffff;
- font-family: var(--mono);
- font-size: clamp(24px, 2.4vw, 34px);
- font-weight: 800;
- letter-spacing: .11em;
- line-height: 1.2;
- overflow: visible;
- }
-
- .device-connection-code button {
- min-height: 38px;
- padding: 8px 12px;
- border: 1px solid rgba(141, 230, 166, .42);
- border-radius: 6px;
- background: rgba(47, 207, 114, .12);
- color: #dff8e6;
- font-weight: 700;
- }
-
- body.phone-client .device-connection-code { display: none !important; }
-
- .pairing-guide {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 10px;
- }
-
- .pairing-progress {
- display: grid;
- gap: 7px;
- min-width: 0;
- }
-
- .pairing-progress > div {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- color: var(--muted);
- font-size: 12px;
- }
-
- .pairing-progress > div strong {
- color: #dce7f4;
- font-size: 12px;
- }
-
- .pairing-progress > div b {
- color: #8fd1ff;
- font-family: var(--mono);
- }
-
- .pairing-progress progress {
- width: 100%;
- height: 9px;
- overflow: hidden;
- border: 0;
- border-radius: 999px;
- background: rgba(255,255,255,.08);
- appearance: none;
- }
-
- .pairing-progress progress::-webkit-progress-bar {
- background: rgba(255,255,255,.08);
- border-radius: 999px;
- }
-
- .pairing-progress progress::-webkit-progress-value {
- background: linear-gradient(90deg, #579fd4, #8fd1ff);
- border-radius: 999px;
- transition: width .25s ease;
- }
-
- .pairing-progress.idle > div {
- justify-content: flex-start;
- }
-
- .pairing-progress.idle [data-pair-progress-label],
- .pairing-progress.idle progress {
- display: none;
- }
-
- .pairing-progress.complete progress::-webkit-progress-value {
- background: #2fcf72;
- }
-
- .pairing-progress.complete > div b,
- .pairing-progress.complete > div strong {
- color: #8de6a6;
- }
-
- .pairing-step {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- gap: 6px 9px;
- align-items: start;
- min-width: 0;
- min-height: 76px;
- padding: 12px;
- border: 1px solid rgba(255,255,255,.08);
- border-radius: 7px;
- background: rgba(255,255,255,.035);
- }
-
- .pairing-step b {
- display: inline-grid;
- place-items: center;
- width: 22px;
- height: 22px;
- border-radius: 999px;
- background: var(--surface-3);
- color: #dce7f4;
- font-size: 12px;
- }
-
- .pairing-step strong {
- color: #f5f7fb;
- font-size: 14px;
- line-height: 1.25;
- }
-
- .pairing-step span {
- grid-column: 2;
- color: var(--muted);
- font-size: 12px;
- line-height: 1.35;
- }
-
- .pairing-step.active {
- border-color: rgba(143,209,255,.34);
- background: rgba(143,209,255,.08);
- }
-
- .pairing-step.done b {
- background: #2fcf72;
- color: #102018;
- }
-
- .connect-status {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- gap: 6px 10px;
- padding: 10px;
- border-radius: 6px;
- background: rgba(255,255,255,.035);
- }
-
- .connect-status span {
- min-width: 0;
- line-height: 1.35;
- }
-
- .connect-status span:empty {
- display: none;
- }
-
- .connect-links {
- border-top: 1px solid rgba(255,255,255,.08);
- padding-top: 10px;
- }
-
- .connect-links summary {
- cursor: pointer;
- color: #dce7f4;
- font-size: 13px;
- list-style-position: inside;
- }
-
- .connect-link-list {
- display: grid;
- gap: 6px;
- margin-top: 8px;
- }
-
- .advanced-cert-title {
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px solid rgba(255,255,255,.08);
- color: var(--muted);
- font-size: 12px;
- font-weight: 700;
- }
-
- .public-endpoint-panel {
- display: grid;
- gap: 8px;
- margin-top: 8px;
- }
-
- .public-endpoint-panel label,
- .public-endpoint-panel small,
- .public-endpoint-status,
- .public-endpoint-id {
- color: var(--muted);
- font-size: 12px;
- line-height: 1.45;
- }
-
- .public-endpoint-status,
- .public-endpoint-id {
- margin: 0;
- }
-
- .public-endpoint-id code {
- color: #dce7f4;
- font-size: 12px;
- user-select: all;
- }
-
- .public-endpoint-controls {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto auto;
- gap: 6px;
- }
-
- .public-endpoint-controls input {
- min-width: 0;
- min-height: 34px;
- border: 1px solid rgba(255,255,255,.16);
- border-radius: 6px;
- padding: 6px 8px;
- color: #eef6ff;
- background: rgba(0,0,0,.22);
- font: inherit;
- font-size: 12px;
- }
-
- .public-endpoint-controls button {
- min-height: 34px;
- white-space: nowrap;
- }
-
- body.phone-client .public-endpoint-panel {
- display: none !important;
- }
-
- .connect-copy a {
- color: var(--blue);
- overflow-wrap: anywhere;
- }
-
- .driver-state,
- .wake-state,
- .trust-state,
- .device-state,
- .app-state {
- color: var(--muted);
- font-size: 13px;
- grid-column: 1 / -1;
- }
-
- .driver-state {
- order: 5;
- }
-
- .wake-state.good {
- color: #8de6a6;
- }
-
- .wake-state.warn {
- color: #ffd27a;
- }
-
- .app-state.good {
- color: #8de6a6;
- }
-
- .app-state.warn {
- color: #ffd27a;
- }
-
- .stream-capability-state.good {
- color: #8de6a6;
- }
-
- .stream-capability-state.warn {
- color: #ffd27a;
- }
-
- .trust-state.good {
- color: #8de6a6;
- }
-
- .trust-state.warn {
- color: #ffd27a;
- }
-
- .trusted-devices {
- display: grid;
- gap: 8px;
- margin-top: 2px;
- padding: 10px;
- border: 1px solid rgba(143,209,255,.18);
- border-radius: 8px;
- background: rgba(255,255,255,.035);
- }
-
- .trusted-devices[hidden] {
- display: none;
- }
-
- .trusted-devices-head {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto auto auto;
- gap: 8px;
- align-items: center;
- }
-
- .trusted-devices-head strong {
- color: #e8f2ff;
- font-size: 13px;
- }
-
- /* Icon-only controls in the device head. Do NOT apply this to labeled
- buttons (+ Add) — fixed 30×30 + padding:0 caused text breakout. */
- .trusted-devices-head #refreshTrustedDevices,
- .trusted-devices-head .device-management-more > summary {
- display: grid;
- place-items: center;
- min-width: 30px;
- width: 30px;
- height: 30px;
- min-height: 30px;
- padding: 0;
- border-radius: 999px;
- }
-
- /* Labeled actions: width follows content in every locale. */
- .trusted-devices-head #openNewDevicePanel {
- min-width: 0;
- width: max-content;
- max-width: 100%;
- height: 30px;
- min-height: 30px;
- padding: 0 11px;
- border-radius: 999px;
- font-size: 12px;
- font-weight: 700;
- line-height: 1;
- white-space: nowrap;
- letter-spacing: 0;
- }
-
- .trusted-devices-head button[data-clear-trusted-devices] {
- border-color: rgba(255, 115, 115, .5);
- border-radius: 6px;
- color: #ffb4b4;
- font-size: 12px;
- }
-
- .device-management-more {
- position: relative;
- }
-
- .device-management-more > summary {
- border: 1px solid #3a4a5f;
- background: #223040;
- color: var(--text);
- cursor: pointer;
- font-size: 18px;
- line-height: 1;
- list-style: none;
- }
-
- .device-management-more > summary::-webkit-details-marker {
- display: none;
- }
-
- .device-management-more > div {
- position: absolute;
- top: calc(100% + 6px);
- right: 0;
- z-index: 20;
- min-width: max(150px, max-content);
- max-width: min(280px, 80vw);
- padding: 6px;
- border: 1px solid rgba(255,255,255,.16);
- border-radius: 8px;
- background: #111922;
- box-shadow: 0 12px 30px rgba(0,0,0,.35);
- box-sizing: border-box;
- }
-
- .device-management-more > div button {
- width: 100%;
- height: auto;
- min-height: 34px;
- min-width: 0;
- padding: 7px 10px;
- white-space: normal;
- text-align: left;
- line-height: 1.25;
- overflow-wrap: anywhere;
- }
-
- .trusted-device-list {
- display: grid;
- gap: 6px;
- min-width: 0;
- }
-
- .trusted-device-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 8px;
- align-items: center;
- min-width: 0;
- padding: 8px;
- border: 1px solid rgba(255,255,255,.09);
- border-radius: 6px;
- background: rgba(10,16,24,.28);
- }
-
- .trusted-device-main {
- display: grid;
- gap: 3px;
- min-width: 0;
- }
-
- .trusted-device-name-row {
- display: flex;
- align-items: center;
- gap: 8px;
- min-width: 0;
- }
-
- .trusted-device-name-row strong {
- min-width: 0;
- flex: 1 1 auto;
- }
-
- .trusted-device-status {
- flex: 0 0 auto;
- padding: 2px 7px;
- border: 1px solid rgba(255,255,255,.18);
- border-radius: 999px;
- color: #8d9aa8;
- font-size: 11px;
- line-height: 1.2;
- }
-
- .trusted-device-row.is-connected {
- border-color: rgba(112, 217, 139, .58);
- background: rgba(24, 72, 45, .28);
- }
-
- .trusted-device-row.is-connected .trusted-device-status {
- border-color: #70d98b;
- color: #baf4c9;
- font-weight: 800;
- }
-
- .trusted-device-main strong,
- .trusted-device-main span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .trusted-device-main strong {
- color: #f5f7fb;
- font-size: 13px;
- }
-
- .trusted-device-main span,
- .trusted-devices-empty {
- color: #b9c4d0;
- font-size: 12px;
- }
-
- .trusted-device-row button {
- padding: 6px 9px;
- font-size: 12px;
- }
-
-
- select,
- button,
- input {
- background: #223040;
- color: var(--text);
- border: 1px solid #3a4a5f;
- border-radius: 6px;
- padding: 8px 10px;
- font: inherit;
- max-width: 100%;
- min-width: 0;
- }
-
- input {
- width: 88px;
- }
-
- #streamFps,
- #streamQuality {
- width: 72px;
- }
-
- button {
- cursor: pointer;
- }
-
- .sideboard-view {
- width: min(100%, 920px);
- justify-self: center;
- align-self: center;
- }
-
- .quota-view {
- width: min(100%, 860px);
- justify-self: center;
- align-self: center;
- min-height: 0;
- }
-
- .quota-shell {
- --quota-accent: #70d98b;
- --quota-blue: #66c5e8;
- --quota-ink: #eff7f1;
- --quota-muted: rgba(239,247,241,.62);
- --quota-panel: rgba(14, 22, 23, .76);
- display: grid;
- grid-template-rows: auto minmax(0, 1fr) auto;
- gap: 12px;
- width: 100%;
- min-height: 360px;
- padding: 12px;
- overflow: hidden;
- color: var(--quota-ink);
- background:
- linear-gradient(135deg, rgba(6, 12, 15, .92), rgba(20, 27, 25, .94)),
- url("/sideboard-skins/command.png") center / cover;
- background-blend-mode: multiply;
- border: 1px solid rgba(255,255,255,.14);
- border-radius: 8px;
- }
-
- .quota-top {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 10px;
- align-items: end;
- }
-
- .quota-left {
- display: grid;
- gap: 8px;
- min-width: 0;
- }
-
- .quota-title {
- display: flex;
- align-items: baseline;
- gap: 8px;
- min-width: 0;
- white-space: nowrap;
- }
-
- .quota-title strong {
- font-size: clamp(18px, 3.6vw, 28px);
- line-height: 1;
- }
-
- .quota-title span,
- .quota-status {
- color: var(--quota-muted);
- font-size: clamp(10px, 1.45vw, 13px);
- }
-
- .quota-tabs {
- display: flex;
- gap: 5px;
- min-width: 0;
- overflow-x: auto;
- scrollbar-width: none;
- }
-
- .quota-tabs::-webkit-scrollbar {
- display: none;
- }
-
- .quota-tab {
- min-height: 28px;
- padding: 5px 11px;
- border: 1px solid rgba(255,255,255,.12);
- border-radius: 999px;
- background: rgba(255,255,255,.055);
- color: rgba(239,247,241,.72);
- font-size: 12px;
- font-weight: 800;
- line-height: 1;
- white-space: nowrap;
- }
-
- .quota-tab.active {
- border-color: rgba(112,217,139,.48);
- background: rgba(112,217,139,.2);
- color: var(--quota-ink);
- }
-
- .quota-help {
- min-width: 0;
- }
-
- .quota-help:empty {
- display: none;
- }
-
- .quota-help-block {
- border: 1px solid rgba(112, 217, 139, .22);
- border-radius: 8px;
- background: rgba(8, 14, 16, .72);
- color: var(--quota-muted);
- font-size: clamp(11px, 1.5vw, 13px);
- line-height: 1.45;
- overflow: hidden;
- }
-
- .quota-help-summary {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- min-height: 36px;
- padding: 8px 12px;
- cursor: pointer;
- list-style: none;
- color: var(--quota-ink);
- font-size: clamp(12px, 1.7vw, 14px);
- font-weight: 800;
- user-select: none;
- }
-
- .quota-help-summary::-webkit-details-marker {
- display: none;
- }
-
- .quota-help-summary::after {
- content: "▸";
- flex: 0 0 auto;
- color: var(--quota-muted);
- font-size: 12px;
- transition: transform .12s ease;
- }
-
- .quota-help-block[open] > .quota-help-summary::after {
- transform: rotate(90deg);
- }
-
- .quota-help-summary-label {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .quota-help-summary-hint {
- flex: 0 0 auto;
- padding: 2px 7px;
- border: 1px solid rgba(112, 217, 139, .35);
- border-radius: 999px;
- background: rgba(112, 217, 139, .12);
- color: var(--quota-accent);
- font-size: 10px;
- font-weight: 800;
- letter-spacing: .04em;
- text-transform: uppercase;
- }
-
- .quota-help-body {
- padding: 0 12px 10px;
- border-top: 1px solid rgba(255, 255, 255, .08);
- }
-
- .quota-help-block ol {
- margin: 8px 0 0;
- padding-left: 1.2em;
- }
-
- .quota-help-block li + li {
- margin-top: 4px;
- }
-
- body.eink-client .quota-help-summary::after {
- transition: none;
- }
-
- .quota-setup-card {
- display: grid;
- gap: 10px;
- align-content: start;
- }
-
- .quota-setup-title {
- font-size: clamp(15px, 2.4vw, 18px);
- font-weight: 800;
- color: var(--quota-ink);
- }
-
- .quota-setup-list {
- margin: 0;
- padding-left: 1.15em;
- color: var(--quota-muted);
- font-size: clamp(11px, 1.5vw, 13px);
- line-height: 1.45;
- }
-
- .quota-setup-list li + li {
- margin-top: 4px;
- }
-
- .quota-setup-list b {
- color: var(--quota-accent);
- }
-
- .quota-grid {
- display: grid;
- grid-template-columns: 1fr;
- gap: 8px;
- min-height: 0;
- align-content: start;
- }
-
- .quota-account-page {
- display: grid;
- min-height: 0;
- }
-
- .quota-account-switcher {
- display: flex;
- gap: 5px;
- align-items: center;
- justify-self: end;
- color: var(--quota-muted);
- font-size: 11px;
- font-weight: 800;
- white-space: nowrap;
- }
-
- .quota-page-button {
- display: grid;
- place-items: center;
- width: 24px;
- height: 24px;
- min-height: 24px;
- padding: 0;
- border: 1px solid rgba(255,255,255,.12);
- border-radius: 999px;
- background: rgba(255,255,255,.06);
- color: var(--quota-ink);
- font-size: 15px;
- line-height: 1;
- }
-
- .quota-dots {
- display: flex;
- gap: 4px;
- align-items: center;
- }
-
- .quota-dot {
- width: 5px;
- height: 5px;
- border-radius: 999px;
- background: rgba(239,247,241,.34);
- }
-
- .quota-dot.active {
- width: 14px;
- background: var(--quota-accent);
- }
-
- .quota-account-card {
- display: grid;
- grid-template-rows: auto auto auto auto auto;
- gap: 12px;
- min-height: 0;
- padding: 14px;
- border: 1px solid rgba(255,255,255,.12);
- background:
- linear-gradient(135deg, rgba(255,255,255,.08), rgba(255,255,255,.025)),
- var(--quota-panel);
- box-shadow: inset 0 1px 0 rgba(255,255,255,.08);
- overflow: hidden;
- }
-
- .quota-account-head {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr) auto auto;
- gap: 10px;
- align-items: center;
- min-width: 0;
- }
-
- .quota-check {
- width: 15px;
- height: 15px;
- border: 1px solid rgba(239,247,241,.72);
- background: rgba(255,255,255,.05);
- }
-
- .quota-account-email {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: clamp(13px, 2vw, 17px);
- font-weight: 800;
- }
-
- .quota-pill {
- padding: 3px 8px;
- border-radius: 999px;
- background: linear-gradient(135deg, #2f9cea, #176ed4);
- color: #fff;
- font-size: 11px;
- font-weight: 800;
- line-height: 1;
- white-space: nowrap;
- }
-
- .quota-active-badge {
- padding: 3px 7px;
- border-radius: 999px;
- background: rgba(70, 210, 137, .18);
- border: 1px solid rgba(112, 236, 166, .55);
- color: #8ff0b6;
- font-size: 10px;
- font-weight: 800;
- white-space: nowrap;
- }
-
- .quota-pair {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 14px;
- min-height: 0;
- }
-
- .quota-pair.single {
- grid-template-columns: 1fr;
- }
-
- .quota-provider {
- display: grid;
- grid-template-rows: auto auto auto;
- gap: 10px;
- min-width: 0;
- }
-
- .quota-provider-title {
- color: rgba(239,247,241,.9);
- font-size: clamp(12px, 1.8vw, 15px);
- font-weight: 800;
- }
-
- .quota-window {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- grid-template-rows: auto auto auto;
- gap: 5px 8px;
- min-width: 0;
- }
-
- .quota-window span {
- color: rgba(239,247,241,.72);
- font-size: clamp(10px, 1.45vw, 12px);
- font-weight: 700;
- }
-
- .quota-window b {
- color: var(--quota-accent);
- font-size: clamp(13px, 2vw, 18px);
- line-height: 1;
- letter-spacing: 0;
- white-space: nowrap;
- }
-
- .quota-bar {
- grid-column: 1 / -1;
- height: 5px;
- background: rgba(255,255,255,.14);
- overflow: hidden;
- border-radius: 999px;
- }
-
- .quota-bar i {
- display: block;
- width: 0%;
- height: 100%;
- background: var(--quota-accent);
- border-radius: inherit;
- }
-
- .quota-window small {
- grid-column: 1 / -1;
- color: var(--quota-muted);
- font-size: clamp(9px, 1.25vw, 11px);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .quota-credit-row {
- display: flex;
- gap: 8px;
- align-items: center;
- color: rgba(239,247,241,.84);
- font-size: 17px;
- line-height: 1.2;
- font-weight: 800;
- }
-
- .quota-footer {
- display: grid;
- grid-template-columns: minmax(0, auto) 1fr auto;
- gap: 10px;
- align-items: center;
- padding-top: 10px;
- border-top: 1px dashed rgba(255,255,255,.14);
- }
-
- .quota-time {
- padding: 5px 10px;
- border-radius: 999px;
- background: rgba(255,255,255,.075);
- color: var(--quota-muted);
- font-size: clamp(10px, 1.35vw, 12px);
- white-space: nowrap;
- }
-
- .quota-toolbox {
- justify-self: end;
- display: flex;
- gap: 4px;
- padding: 4px;
- border: 1px solid rgba(255,255,255,.12);
- border-radius: 999px;
- background: rgba(255,255,255,.06);
- }
-
- .quota-toolbox button {
- display: grid;
- place-items: center;
- width: 28px;
- height: 28px;
- min-height: 28px;
- padding: 0;
- border: 0;
- border-radius: 999px;
- background: transparent;
- color: rgba(239,247,241,.82);
- font-size: 14px;
- }
-
- body:not(.eink-client) .quota-toolbox button {
- width: auto;
- min-width: 28px;
- padding: 0 8px;
- font-size: 11px;
- font-weight: 700;
- white-space: nowrap;
- }
-
- .quota-toolbox button:hover {
- background: rgba(255,255,255,.1);
- }
-
- .quota-toolbox button:disabled {
- cursor: wait;
- opacity: .48;
- }
-
- .quota-action-status {
- min-height: 17px;
- color: var(--quota-muted);
- font-size: clamp(9px, 1.2vw, 11px);
- line-height: 1.35;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .quota-action-status.working {
- color: var(--quota-blue);
- }
-
- .quota-action-status.success {
- color: var(--quota-accent);
- }
-
- .quota-action-status.error {
- color: #ff8a7a;
- }
-
- .quota-local-strip {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- min-height: 0;
- }
-
- .quota-local-card {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 5px 8px;
- min-width: 0;
- padding: 8px 10px;
- border: 1px solid rgba(255,255,255,.1);
- background: rgba(255,255,255,.045);
- color: rgba(239,247,241,.78);
- }
-
- .quota-local-card strong,
- .quota-local-card span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- font-size: 12px;
- }
-
- .quota-local-card b {
- color: var(--quota-blue);
- font-size: 12px;
- white-space: nowrap;
- }
-
- body.viewer-fullscreen.mode-sideboard .sideboard-view.active,
- body.dashboard-viewer.mode-sideboard .sideboard-view.active,
- body.deck-window.mode-sideboard .sideboard-view.active {
- width: 100%;
- min-height: var(--viewer-height);
- align-self: stretch;
- }
-
- body.viewer-fullscreen.mode-quota .quota-view.active,
- body.dashboard-viewer.mode-quota .quota-view.active,
- body.deck-window.mode-quota .quota-view.active {
- width: 100%;
- min-height: var(--viewer-height);
- align-self: stretch;
- }
-
- body.viewer-fullscreen.mode-quota .quota-shell,
- body.dashboard-viewer.mode-quota .quota-shell,
- body.deck-window.mode-quota .quota-shell {
- min-height: var(--viewer-height);
- height: auto;
- border: 0;
- border-radius: 0;
- padding: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- }
-
- body.deck-window.mode-quota .quota-shell {
- grid-template-rows: auto minmax(0, 1fr);
- height: 100vh;
- min-height: 0;
- }
-
- body.deck-window.mode-quota .quota-grid,
- body.deck-window.mode-quota .quota-account-page {
- height: 100%;
- min-height: 0;
- align-content: stretch;
- }
-
- body.viewer-fullscreen.mode-quota .quota-account-card,
- body.dashboard-viewer.mode-quota .quota-account-card,
- body.deck-window.mode-quota .quota-account-card {
- gap: 8px;
- padding: 12px 14px;
- }
-
- body.deck-window.mode-quota .quota-account-card {
- height: 100%;
- grid-template-rows: auto minmax(0, 1fr) auto auto auto;
- }
-
- body.deck-window.mode-quota .quota-pair {
- align-items: stretch;
- }
-
- body.deck-window.mode-quota .quota-pair.single {
- width: min(100%, 760px);
- align-self: center;
- }
-
- body.viewer-fullscreen.mode-quota .quota-provider,
- body.dashboard-viewer.mode-quota .quota-provider,
- body.deck-window.mode-quota .quota-provider {
- gap: 7px;
- }
-
- body.deck-window.mode-quota .quota-provider {
- align-content: center;
- }
-
- body.viewer-fullscreen.mode-quota .quota-window,
- body.dashboard-viewer.mode-quota .quota-window {
- gap: 3px 8px;
- }
-
- body.deck-window.mode-quota .quota-title strong,
- body.deck-window.mode-quota .quota-window b {
- line-height: 1.08;
- }
-
- body.viewer-fullscreen.mode-quota .quota-footer,
- body.dashboard-viewer.mode-quota .quota-footer {
- padding-top: 8px;
- }
-
- body.deck-window.mode-quota .quota-footer {
- grid-template-columns: auto auto minmax(0, 1fr);
- justify-items: start;
- }
-
- body.deck-window.mode-quota .quota-toolbox {
- justify-self: start;
- }
-
- body.viewport-portrait .quota-view {
- width: 100%;
- justify-self: stretch;
- align-self: start;
- }
-
- body.viewport-portrait .quota-shell {
- grid-template-rows: auto auto;
- align-content: start;
- min-height: auto;
- height: auto;
- overflow: visible;
- padding: 12px 12px calc(18px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) 12px;
- }
-
- body.viewport-portrait .quota-top {
- grid-template-columns: 1fr;
- align-items: start;
- }
-
- body.viewport-portrait .quota-status {
- justify-self: start;
- }
-
- body.viewport-portrait .quota-title {
- flex-wrap: wrap;
- white-space: normal;
- align-items: center;
- }
-
- body.viewport-portrait .quota-title strong {
- font-size: clamp(18px, 6.5vw, 30px);
- }
-
- body.viewport-portrait .quota-title span,
- body.viewport-portrait .quota-status {
- font-size: clamp(11px, 3.3vw, 14px);
- }
-
- body.viewport-portrait .quota-tabs {
- gap: 6px;
- }
-
- body.viewport-portrait .quota-tab {
- min-height: 32px;
- padding: 7px 12px;
- }
-
- body.viewport-portrait .quota-grid {
- overflow: visible;
- }
-
- body.viewport-portrait .quota-account-card {
- gap: 14px;
- overflow: visible;
- }
-
- body.viewport-portrait .quota-account-head {
- grid-template-columns: auto minmax(0, 1fr) auto;
- grid-template-rows: auto auto;
- align-items: start;
- }
-
- body.viewport-portrait .quota-check {
- grid-row: 1;
- }
-
- body.viewport-portrait .quota-account-email {
- white-space: normal;
- overflow: visible;
- text-overflow: clip;
- line-height: 1.2;
- font-size: clamp(15px, 4.8vw, 22px);
- }
-
- body.viewport-portrait .quota-account-switcher {
- grid-column: 2 / -1;
- grid-row: 2;
- justify-self: start;
- }
-
- body.viewport-portrait .quota-pill {
- grid-column: 3;
- grid-row: 1;
- justify-self: end;
- }
-
- body.viewport-portrait .quota-pair {
- grid-template-columns: 1fr;
- gap: 16px;
- }
-
- body.viewport-portrait .quota-provider {
- gap: 12px;
- }
-
- body.viewport-portrait .quota-provider-title {
- font-size: clamp(14px, 4.2vw, 18px);
- }
-
- body.viewport-portrait .quota-window {
- gap: 6px 8px;
- }
-
- body.viewport-portrait .quota-window span {
- font-size: clamp(11px, 3.4vw, 14px);
- }
-
- body.viewport-portrait .quota-window b {
- font-size: clamp(16px, 5vw, 22px);
- }
-
- body.viewport-portrait .quota-window small {
- white-space: normal;
- overflow: visible;
- text-overflow: clip;
- line-height: 1.2;
- }
-
- body.viewport-portrait .quota-credit-row {
- flex-wrap: wrap;
- line-height: 1.25;
- }
-
- body.viewport-portrait .quota-footer {
- grid-template-columns: 1fr;
- justify-items: start;
- gap: 8px;
- }
-
- body.viewport-portrait .quota-toolbox {
- justify-self: start;
- flex-wrap: wrap;
- }
-
- body.viewport-portrait .quota-action-status {
- white-space: normal;
- }
-
- body.viewer-fullscreen.mode-quota.viewport-portrait main,
- body.dashboard-viewer.mode-quota.viewport-portrait main {
- overflow-y: auto;
- }
-
- body.viewer-fullscreen.mode-quota.viewport-portrait .quota-view.active,
- body.dashboard-viewer.mode-quota.viewport-portrait .quota-view.active {
- width: 100%;
- height: auto;
- min-height: var(--viewer-height);
- align-self: start;
- align-content: start;
- overflow-y: auto;
- }
-
- body.viewer-fullscreen.mode-quota.viewport-portrait .quota-shell,
- body.dashboard-viewer.mode-quota.viewport-portrait .quota-shell {
- align-content: start;
- min-height: var(--viewer-height);
- height: auto;
- overflow: visible;
- padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(18px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- }
-
- .sideboard-shell {
- --side-accent: #f2b45f;
- --side-accent-2: #7ed9e8;
- --side-good: #91dd98;
- --side-warn: #ffd27a;
- --side-bg: #071015;
- --side-panel: rgba(17, 25, 30, 0.88);
- --side-line: rgba(255, 255, 255, 0.16);
- --skin-image: url("/sideboard-skins/command.png");
- position: relative;
- display: grid;
- grid-template-columns: minmax(190px, 1.05fr) minmax(260px, 1.38fr) minmax(190px, 1fr);
- grid-template-rows: auto minmax(0, 1fr);
- gap: 8px;
- width: 100%;
- min-height: 360px;
- padding: 12px;
- overflow: hidden;
- color: #f7f2e8;
- background: var(--side-bg);
- border: 1px solid rgba(255,255,255,.18);
- border-radius: 8px;
- }
-
- .sideboard-shell::before {
- content: "";
- position: absolute;
- inset: 0;
- background-image: var(--skin-image);
- background-position: center;
- background-size: cover;
- opacity: .68;
- transform: scale(1.015);
- }
-
- .sideboard-shell::after {
- content: "";
- position: absolute;
- inset: 0;
- background:
- linear-gradient(90deg, rgba(3,6,8,.76), rgba(3,6,8,.48) 44%, rgba(3,6,8,.74)),
- radial-gradient(circle at 50% 42%, rgba(0,0,0,.08), rgba(0,0,0,.58) 72%);
- }
-
- .sideboard-shell > * {
- position: relative;
- z-index: 1;
- }
-
- body.viewer-fullscreen.mode-sideboard .sideboard-shell,
- body.dashboard-viewer.mode-sideboard .sideboard-shell,
- body.deck-window.mode-sideboard .sideboard-shell {
- min-height: var(--viewer-height);
- height: auto;
- border: 0;
- border-radius: 0;
- padding: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- }
-
- body.eink-client.viewer-fullscreen.mode-sideboard .sideboard-shell,
- body.eink-client.dashboard-viewer.mode-sideboard .sideboard-shell,
- body.eink-client.viewer-immersive.mode-sideboard .sideboard-shell {
- min-height: 0;
- height: auto;
- overflow: visible;
- }
-
- body.deck-window.mode-sideboard .sideboard-shell {
- height: 100vh;
- min-height: 0;
- }
-
- body.deck-window.mode-sideboard .sideboard-shell::before {
- transform: none;
- }
-
- body.deck-window.mode-sideboard .side-title strong,
- body.deck-window.mode-sideboard .metric-value {
- line-height: 1.08;
- }
-
- body.deck-window.mode-sideboard .side-big {
- line-height: 1;
- }
-
- .sideboard-shell.skin-dial {
- --side-accent: #7ed9e8;
- --side-accent-2: #f2b45f;
- --side-bg: #061116;
- --skin-image: url("/sideboard-skins/dial.png");
- }
-
- .sideboard-shell.skin-focus {
- --side-accent: #91dd98;
- --side-accent-2: #e0d5aa;
- --side-bg: #0c130e;
- --side-panel: rgba(25, 36, 25, 0.88);
- --skin-image: url("/sideboard-skins/focus.png");
- }
-
- .sideboard-top {
- grid-column: 1 / -1;
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 8px;
- align-items: center;
- min-width: 0;
- }
-
- .side-title {
- display: flex;
- align-items: baseline;
- gap: 8px;
- min-width: 0;
- white-space: nowrap;
- }
-
- .side-title strong {
- font-size: clamp(16px, 3.4vw, 28px);
- line-height: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- }
-
- .side-title span,
- .side-updated {
- color: rgba(247,242,232,.68);
- font-size: clamp(10px, 1.5vw, 13px);
- }
-
- .skin-picker {
- display: flex;
- gap: 5px;
- }
-
- .skin-picker button {
- min-width: 34px;
- min-height: 30px;
- padding: 4px 8px;
- border-radius: 4px;
- background: rgba(255,255,255,.08);
- color: rgba(247,242,232,.78);
- }
-
- .skin-picker button.active {
- background: var(--side-accent);
- color: #071015;
- border-color: var(--side-accent);
- }
-
- .side-panel {
- display: grid;
- min-width: 0;
- min-height: 0;
- padding: 8px;
- border: 1px solid var(--side-line);
- background: var(--side-panel);
- overflow: hidden;
- }
-
- .side-load {
- align-content: center;
- gap: 8px;
- border-right: 1px solid rgba(255,255,255,.22);
- }
-
- .side-kicker {
- color: rgba(247,242,232,.68);
- font-size: clamp(9px, 1.2vw, 11px);
- line-height: 1;
- text-transform: uppercase;
- }
-
- .side-big {
- color: var(--side-accent);
- font-size: clamp(54px, 10.4vw, 84px);
- font-weight: 800;
- line-height: .88;
- letter-spacing: 0;
- }
-
- .side-error {
- color: var(--side-warn);
- font-size: clamp(10px, 1.5vw, 13px);
- line-height: 1.25;
- }
-
- .side-summary {
- display: grid;
- gap: 4px;
- color: rgba(247,242,232,.78);
- font-size: clamp(10px, 1.5vw, 13px);
- line-height: 1.25;
- }
-
- .metric-grid {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- grid-template-rows: repeat(3, minmax(0, 1fr));
- gap: 6px;
- }
-
- .metric-card {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- grid-template-rows: auto 1fr auto;
- gap: 3px 6px;
- min-width: 0;
- min-height: 0;
- padding: 8px;
- border: 1px solid rgba(255,255,255,.09);
- background: rgba(255,255,255,.03);
- backdrop-filter: blur(8px);
- -webkit-backdrop-filter: blur(8px);
- border-radius: 8px;
- transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.2s ease, border-color 0.2s ease;
- }
-
- .metric-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 6px 20px rgba(0, 0, 0, .32);
- border-color: rgba(143, 209, 255, .22);
- }
-
- .metric-card .side-kicker {
- grid-column: 1;
- }
-
- .metric-value {
- grid-column: 2;
- grid-row: 1 / 3;
- align-self: center;
- color: #fff;
- font-size: clamp(21px, 3.7vw, 32px);
- font-weight: 800;
- line-height: 1;
- white-space: nowrap;
- font-family: "Share Tech Mono", monospace;
- }
-
- .metric-sub {
- grid-column: 1 / -1;
- color: rgba(247,242,232,.68);
- font-size: clamp(9px, 1.35vw, 12px);
- line-height: 1.2;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .mini-bar {
- grid-column: 1 / -1;
- height: 4px;
- background: rgba(255,255,255,.1);
- border-radius: 999px;
- overflow: hidden;
- }
-
- .mini-bar span {
- display: block;
- width: 0%;
- height: 100%;
- background: var(--grad-accent);
- border-radius: 999px;
- transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
- }
-
- .side-feed {
- grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
- gap: 6px;
- }
-
- .feed-list {
- display: grid;
- gap: 4px;
- min-height: 0;
- margin: 0;
- padding: 0;
- list-style: none;
- overflow: hidden;
- color: rgba(247,242,232,.82);
- font-size: clamp(10px, 1.45vw, 13px);
- line-height: 1.25;
- }
-
- .feed-list li {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .process-list li {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 6px;
- }
-
- /* Windows console: settings are a dedicated product page. Everyday views
- keep a compact header and own the entire remaining viewport. */
- body.pc-console {
- height: 100dvh;
- min-height: 0;
- overflow: hidden;
- }
-
- body.pc-console:not(.mode-setup) header .setup-panels,
- body.pc-console:not(.mode-setup) header .device-management-panel,
- body.pc-console:not(.mode-setup) header .new-device-panel,
- body.pc-console:not(.mode-setup) header .diagnostics-panel,
- body.pc-console:not(.mode-setup) header .driver-state {
- display: none !important;
- }
-
- body.pc-console.mode-setup {
- grid-template-rows: minmax(0, 1fr);
- }
-
- body.pc-console.mode-setup header {
- align-content: start;
- grid-auto-rows: max-content;
- overflow-x: hidden;
- overflow-y: auto;
- }
-
- body.pc-console.mode-setup main {
- display: none;
- }
-
- body.pc-console:not(.mode-setup) main {
- min-height: 0;
- overflow: hidden;
- }
-
- body.pc-console:not(.mode-setup) .app-view.active {
- width: 100%;
- max-width: none;
- height: 100%;
- min-height: 0;
- align-self: stretch;
- justify-self: stretch;
- }
-
- body.pc-console.mode-display .screen-media {
- width: 100%;
- max-width: none;
- height: 100%;
- max-height: 100%;
- aspect-ratio: auto;
- object-fit: contain;
- }
-
- body.pc-console.mode-sideboard .sideboard-shell,
- body.pc-console.mode-quota .quota-shell {
- width: 100%;
- height: 100%;
- min-height: 0;
- }
-
- body.pc-console.mode-quota .quota-shell {
- overflow: hidden;
- }
-
- .process-list li > span {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .process-list li > b {
- white-space: nowrap;
- }
-
- .feed-heading {
- color: var(--side-accent-2);
- font-size: clamp(10px, 1.4vw, 12px);
- text-transform: uppercase;
- }
-
- .sideboard-subpage {
- grid-column: 1 / -1;
- min-width: 0;
- }
-
- #systemSideboardPage {
- display: contents;
- }
-
- /* display:contents means this element has no box of its own, so the
- .sideboard-shell > * { z-index:1 } rule doesn't lift it above the
- shell's dark ::after overlay. Lift its children explicitly. */
- #systemSideboardPage > * {
- position: relative;
- z-index: 1;
- }
-
- /* 子頁 hidden;面板開合由 JS inline display !important 負責 */
- #systemSideboardPage[hidden],
- #customSideboardPage[hidden],
- .custom-cards-toolbar button[hidden],
- .custom-manager-header button[hidden] {
- display: none !important;
- }
-
- .sideboard-page-tabs {
- grid-column: 1 / -1;
- display: flex;
- gap: 5px;
- min-width: 0;
- }
-
- .sideboard-page-tabs button {
- min-height: 28px;
- padding: 4px 12px;
- border-radius: 999px;
- border-color: rgba(255,255,255,.14);
- background: rgba(255,255,255,.06);
- color: rgba(247,242,232,.72);
- font-size: 11px;
- }
-
- .sideboard-page-tabs button.active {
- border-color: var(--side-accent);
- background: var(--side-accent);
- color: #071015;
- }
-
- .custom-sideboard-page {
- display: grid;
- grid-template-rows: auto auto auto minmax(0, 1fr) auto;
- gap: 8px;
- min-height: 0;
- min-width: 0;
- overflow: auto;
- }
-
- .windows-notification-control {
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: center;
- gap: 3px 10px;
- padding: 8px 10px;
- border: 1px solid rgba(255,255,255,.12);
- border-radius: 8px;
- background: rgba(255,255,255,.04);
- }
-
- .windows-notification-control > div:first-child {
- display: grid;
- gap: 3px;
- }
-
- .windows-notification-control strong {
- color: var(--side-accent);
- font-size: 13px;
- }
-
- .windows-notification-control span,
- .windows-notification-control p {
- color: rgba(247,242,232,.66);
- font-size: 11px;
- }
-
- .windows-notification-control p {
- grid-column: 1 / 2;
- margin: 0;
- }
-
- .windows-notification-control .custom-cards-toolbar {
- grid-column: 2;
- grid-row: 1 / span 2;
- justify-content: flex-end;
- }
-
- .custom-card-settings {
- gap: 8px;
- padding: 10px;
- border: 1px solid var(--side-line);
- border-radius: 8px;
- background: rgba(17,25,30,.92);
- }
-
- .custom-settings-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 10px;
- }
-
- .custom-settings-header > div {
- display: grid;
- gap: 3px;
- }
-
- .custom-settings-header strong {
- color: var(--side-accent);
- font-size: 14px;
- }
-
- .custom-settings-header span,
- .custom-settings-help {
- color: rgba(247,242,232,.62);
- font-size: 11px;
- line-height: 1.35;
- }
-
- .custom-card-settings-form {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- align-items: end;
- gap: 8px;
- }
-
- .custom-card-settings-form label {
- display: grid;
- gap: 4px;
- color: rgba(247,242,232,.72);
- font-size: 11px;
- }
-
- .custom-card-settings-form select,
- .custom-card-settings-form input[type="number"] {
- width: 100%;
- min-height: 30px;
- padding: 5px 7px;
- border: 1px solid rgba(255,255,255,.16);
- border-radius: 5px;
- color: #fff;
- background: rgba(255,255,255,.07);
- }
-
- .custom-card-settings-form .custom-settings-check {
- display: flex;
- align-items: center;
- min-height: 30px;
- gap: 6px;
- }
-
- .custom-settings-check input {
- accent-color: var(--side-accent);
- }
-
- .custom-card-settings-form > .custom-settings-help {
- grid-column: 1 / -1;
- margin: 0;
- }
-
- .custom-card-settings-form .custom-source-form-actions {
- grid-column: 1 / -1;
- }
-
- .custom-cards-top,
- .custom-manager-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- min-width: 0;
- }
-
- .custom-cards-title,
- .custom-manager-header > div {
- display: grid;
- gap: 3px;
- min-width: 0;
- }
-
- .custom-cards-title strong,
- .custom-manager-header strong {
- color: var(--side-accent);
- font-size: clamp(16px, 3vw, 24px);
- }
-
- .custom-cards-title span,
- .custom-manager-header span,
- .custom-source-meta,
- .custom-source-health,
- .custom-source-form label,
- .custom-credential-panel > span {
- color: rgba(247,242,232,.66);
- font-size: 11px;
- }
-
- .custom-cards-toolbar,
- .custom-source-actions,
- .custom-source-form-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
- align-items: center;
- }
-
- .custom-cards-toolbar button,
- .custom-manager-header button,
- .custom-source-actions button,
- .custom-source-form-actions button {
- min-height: 28px;
- padding: 4px 9px;
- border-color: rgba(255,255,255,.16);
- background: rgba(255,255,255,.08);
- color: rgba(247,242,232,.86);
- font-size: 11px;
- }
-
- .custom-cards-toolbar button:hover,
- .custom-manager-header button:hover,
- .custom-source-actions button:hover,
- .custom-source-form-actions button:hover {
- border-color: var(--side-accent);
- }
-
- .custom-cards-status {
- min-height: 1em;
- }
-
- .windows-notification-ok { color: var(--side-good); }
- .windows-notification-warn { color: var(--side-warn); }
-
- .custom-cards-status.ok,
- .custom-source-health.health-active {
- color: var(--side-good);
- }
-
- .custom-cards-status.error,
- .custom-source-health.health-disabled,
- .custom-source-health.health-stale {
- color: var(--side-warn);
- }
-
- .custom-cards-status.muted,
- .custom-source-health.health-waiting {
- color: rgba(247,242,232,.58);
- }
-
- .custom-cards-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
- align-content: start;
- gap: 8px;
- min-width: 0;
- min-height: 0;
- overflow: auto;
- padding: 1px;
- }
-
- .custom-card,
- .custom-empty-state,
- .custom-sources-manager,
- .custom-credential-panel {
- min-width: 0;
- border: 1px solid var(--side-line);
- background: rgba(17,25,30,.9);
- border-radius: 8px;
- }
-
- .custom-card {
- display: grid;
- grid-template-rows: auto minmax(0, 1fr) auto;
- gap: 8px;
- min-height: 142px;
- padding: 10px;
- overflow: hidden;
- }
-
- .custom-card-header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- gap: 8px;
- min-width: 0;
- }
-
- .custom-card-title-block {
- display: grid;
- gap: 3px;
- min-width: 0;
- }
-
- .custom-card-title {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: #fff;
- font-size: 15px;
- }
-
- .custom-card-source,
- .custom-card-footer,
- .custom-card-freshness {
- color: rgba(247,242,232,.58);
- font-size: 10px;
- }
-
- .custom-card-freshness {
- flex: 0 0 auto;
- padding: 2px 5px;
- border: 1px solid rgba(255,255,255,.14);
- border-radius: 999px;
- }
-
- .custom-card-freshness.freshness-fresh {
- color: var(--side-good);
- }
-
- .custom-card-freshness.freshness-stale {
- color: var(--side-warn);
- }
-
- .custom-status-content,
- .custom-metric-content {
- display: grid;
- align-content: center;
- gap: 6px;
- min-height: 0;
- }
-
- .custom-status-value,
- .custom-metric-value {
- color: var(--side-accent);
- font-size: clamp(22px, 4vw, 34px);
- line-height: 1.05;
- overflow-wrap: anywhere;
- }
-
- .custom-metric-unit {
- margin-left: 4px;
- color: rgba(247,242,232,.72);
- font-size: .5em;
- }
-
- .custom-status-detail,
- .custom-card-empty {
- color: rgba(247,242,232,.72);
- font-size: 12px;
- line-height: 1.35;
- overflow-wrap: anywhere;
- }
-
- .custom-progress {
- height: 6px;
- overflow: hidden;
- border-radius: 999px;
- background: rgba(255,255,255,.1);
- }
-
- .custom-progress span {
- display: block;
- height: 100%;
- border-radius: inherit;
- background: var(--side-accent);
- }
-
- .custom-feed-list {
- display: grid;
- align-content: start;
- gap: 6px;
- min-height: 0;
- margin: 0;
- padding: 0;
- overflow: auto;
- list-style: none;
- }
-
- .custom-feed-item {
- min-width: 0;
- padding: 6px 7px;
- border-left: 3px solid var(--side-accent-2);
- background: rgba(255,255,255,.045);
- }
-
- .custom-feed-item.severity-success { border-left-color: var(--side-good); }
- .custom-feed-item.severity-warning { border-left-color: var(--side-warn); }
- .custom-feed-item.severity-error { border-left-color: #ff8d8d; }
-
- .custom-feed-meta {
- display: flex;
- justify-content: space-between;
- gap: 6px;
- color: rgba(247,242,232,.58);
- font-size: 10px;
- }
-
- .custom-feed-text {
- margin-top: 3px;
- color: rgba(247,242,232,.9);
- line-height: 1.35;
- overflow-wrap: anywhere;
- white-space: pre-wrap;
- }
-
- .custom-feed-text.is-streaming::after {
- content: "▋";
- display: inline-block;
- margin-left: 2px;
- color: var(--side-accent);
- animation: custom-stream-caret 650ms steps(1, end) infinite;
- }
-
- @keyframes custom-stream-caret {
- 50% { opacity: 0; }
- }
-
- .custom-key-value-list {
- display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr);
- align-content: start;
- gap: 5px 8px;
- margin: 0;
- overflow: auto;
- }
-
- .custom-key-value-list dt,
- .custom-key-value-list dd {
- min-width: 0;
- margin: 0;
- overflow-wrap: anywhere;
- }
-
- .custom-key-value-list dt { color: rgba(247,242,232,.58); }
- .custom-key-value-list dd { color: rgba(247,242,232,.92); }
-
- .custom-card-footer {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .custom-empty-state {
- grid-column: 1 / -1;
- padding: 24px 16px;
- color: rgba(247,242,232,.7);
- text-align: center;
- }
-
- .custom-sources-manager {
- gap: 8px;
- padding: 10px;
- max-height: min(46vh, 430px);
- overflow: auto;
- }
-
- .custom-manager-header .custom-manager-actions {
- display: flex;
- flex-wrap: wrap;
- gap: 5px;
- align-items: center;
- justify-content: flex-end;
- }
-
- .custom-source-list {
- display: grid;
- gap: 5px;
- }
-
- .custom-source-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- min-width: 0;
- padding: 7px;
- border: 1px solid rgba(255,255,255,.1);
- background: rgba(255,255,255,.035);
- }
-
- .custom-source-summary {
- display: grid;
- gap: 2px;
- min-width: 0;
- }
-
- .custom-source-name {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: #fff;
- font-size: 12px;
- }
-
- .custom-source-actions {
- justify-content: flex-end;
- flex: 0 0 auto;
- }
-
- .custom-source-action {
- padding: 3px 6px !important;
- font-size: 10px !important;
- }
-
- .custom-source-form,
- .custom-credential-panel {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 7px;
- padding: 10px;
- }
-
- .custom-source-form > strong,
- .custom-credential-panel > strong,
- .custom-credential-panel > span,
- .custom-credential-panel > textarea,
- .custom-source-form-actions {
- grid-column: 1 / -1;
- }
-
- .custom-source-form label {
- display: grid;
- gap: 3px;
- min-width: 0;
- }
-
- .custom-source-form input,
- .custom-source-form select,
- .custom-credential-panel textarea {
- width: 100%;
- min-width: 0;
- padding: 6px 7px;
- background: rgba(0,0,0,.22);
- font-size: 12px;
- }
-
- .custom-credential-panel textarea {
- resize: vertical;
- color: rgba(247,242,232,.9);
- font-family: "Cascadia Mono", Consolas, monospace;
- line-height: 1.35;
- }
-
- .custom-manager-empty {
- margin: 0;
- color: rgba(247,242,232,.58);
- font-size: 11px;
- }
-
- /* Phone client: content-first. PC console keeps full connect/pairing chrome. */
- body.phone-client header {
- grid-template-columns: 1fr;
- gap: 10px;
- padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(12px + var(--safe-area-inset-right, env(safe-area-inset-right))) 10px calc(12px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- }
-
- body.phone-client .brand-copy span {
- display: none;
- }
-
- body.phone-client .new-device-panel,
- body.phone-client .device-management-panel,
- body.phone-client .diagnostics-panel,
- body.phone-client.device-trusted .connect-panel,
- body.phone-client.device-trusted .driver-state {
- display: none !important;
- }
-
- body.phone-client.device-trusted:not(.mode-display) .utility-controls,
- body.phone-client.device-trusted:not(.mode-display) .setup-panels {
- display: none !important;
- }
-
- body.phone-client.device-trusted.mode-display #displaySettingsToggle {
- display: inline-flex;
- }
-
- body.phone-client.device-trusted .setup-panels {
- display: none;
- }
-
- body.phone-client.device-trusted.mode-display.display-settings-open .setup-panels {
- display: flex;
- }
-
- /* Unpaired phone: keep a short trust note, hide PC pairing QR / guide. */
- body.phone-client:not(.device-trusted) .connect-panel {
- grid-template-columns: 1fr;
- padding: 12px;
- gap: 10px;
- }
-
- body.phone-client:not(.device-trusted) .connect-qr,
- body.phone-client:not(.device-trusted) .pairing-guide,
- body.phone-client:not(.device-trusted) .connect-title,
- body.phone-client:not(.device-trusted) .connect-links,
- body.phone-client:not(.device-trusted) .trusted-devices,
- body.phone-client:not(.device-trusted) .connect-actions #launchDeckWindow {
- display: none !important;
- }
-
- body.phone-client:not(.device-trusted) .connect-actions {
- grid-template-columns: 1fr;
- }
-
- body.phone-client:not(.device-trusted):not(.pc-console) header,
- body.phone-client:not(.device-trusted):not(.pc-console) .display-view,
- body.phone-client:not(.device-trusted):not(.pc-console) .sideboard-view,
- body.phone-client:not(.device-trusted):not(.pc-console) .quota-view {
- display: none !important;
- }
-
- body.phone-client:not(.device-trusted):not(.pc-console) main {
- display: grid;
- place-items: start center;
- padding-top: max(28px, var(--safe-area-inset-top, env(safe-area-inset-top)));
- }
-
- body.phone-client .panel {
- order: 5;
- }
-
- body.phone-client main {
- padding: 10px;
- padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right)));
- padding-bottom: calc(10px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
- }
-
- body.phone-client.mode-display .screen-media {
- width: 100%;
- max-width: none;
- border-radius: 4px;
- }
-
- /*
- * Force landscape presentation on phone clients held upright.
- * Physical portrait viewport is rotated so the app always lays out as landscape.
- */
- html.phone-force-landscape {
- width: 100%;
- height: 100%;
- overflow: hidden;
- background: #0d131a;
- }
-
- html.phone-force-landscape body.force-landscape {
- position: fixed;
- top: 0;
- left: 100%;
- width: 100dvh;
- height: 100dvw;
- max-width: none;
- max-height: none;
- margin: 0;
- transform: rotate(90deg);
- transform-origin: top left;
- overflow-x: hidden;
- overflow-y: auto;
- -webkit-overflow-scrolling: touch;
- grid-template-rows: auto 1fr;
- }
-
- html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display {
- width: 100dvh;
- height: 100dvw;
- overflow: hidden;
- grid-template-rows: 1fr;
- }
-
- html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display main {
- width: 100%;
- height: 100%;
- padding: 0;
- overflow: hidden;
- }
-
- /* Logical landscape box after CSS rotate — same geometry iOS uses via viewer vars. */
- html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display .screen-media {
- position: fixed;
- left: 50%;
- top: 50%;
- width: var(--viewer-width);
- height: var(--viewer-height);
- max-width: none;
- max-height: none;
- aspect-ratio: auto;
- object-fit: contain;
- border: 0;
- border-radius: 0;
- box-shadow: none;
- transform: translate(-50%, -50%);
- }
-
- html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display .display-view.active {
- width: 100%;
- height: 100%;
- min-height: 0;
- place-items: center;
- }
-
- html.phone-force-landscape body.force-landscape.viewer-fullscreen.mode-display .display-toolbar {
- position: fixed;
- top: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
- left: 50%;
- transform: translateX(-50%);
- }
-
- /*
- * Logical landscape chrome (CSS force-rotate OR real landscape).
- * The generic @media (max-width: 960px) portrait compaction sets
- * utility buttons to width:100%, which turns Fullscreen into a full bar
- * once the rotated logical width exceeds a phone portrait width.
- */
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted header,
- body.phone-client.device-trusted.viewport-landscape header {
- gap: 6px;
- padding: 6px 10px;
- padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left, 0px)));
- padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .brand,
- body.phone-client.device-trusted.viewport-landscape .brand {
- display: none;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .top-controls,
- body.phone-client.device-trusted.viewport-landscape .top-controls {
- display: grid !important;
- grid-template-columns: minmax(0, 1fr) auto !important;
- gap: 8px !important;
- width: 100% !important;
- align-items: center;
- justify-content: stretch;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .view-switcher,
- body.phone-client.device-trusted.viewport-landscape .view-switcher {
- display: flex !important;
- flex-wrap: nowrap !important;
- width: auto !important;
- max-width: 100%;
- min-width: 0;
- justify-self: start;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .view-switcher .mode-button,
- body.phone-client.device-trusted.viewport-landscape .view-switcher .mode-button {
- width: auto !important;
- flex: 0 1 auto;
- min-width: 0;
- padding-inline: 12px;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls,
- body.phone-client.device-trusted.viewport-landscape .utility-controls {
- display: flex !important;
- flex-wrap: nowrap !important;
- width: auto !important;
- max-width: 100%;
- justify-content: flex-end !important;
- align-items: center;
- gap: 6px !important;
- justify-self: end;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls > *,
- body.phone-client.device-trusted.viewport-landscape .utility-controls > * {
- width: auto !important;
- max-width: none;
- flex: 0 0 auto;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls select,
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls button,
- body.phone-client.device-trusted.viewport-landscape .utility-controls select,
- body.phone-client.device-trusted.viewport-landscape .utility-controls button {
- width: auto !important;
- min-width: 0;
- min-height: 36px;
- padding-inline: 10px;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls #fullscreen,
- body.phone-client.device-trusted.viewport-landscape .utility-controls #fullscreen {
- display: inline-flex !important;
- width: auto !important;
- max-width: 140px;
- white-space: nowrap;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .utility-controls .locale-control,
- body.phone-client.device-trusted.viewport-landscape .utility-controls .locale-control {
- display: inline-flex !important;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted main,
- body.phone-client.device-trusted.viewport-landscape main {
- width: 100%;
- min-height: 0;
- padding: 8px 10px;
- padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left, 0px)));
- padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
- padding-bottom: calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted .app-view.active,
- body.phone-client.device-trusted.viewport-landscape .app-view.active {
- width: 100%;
- max-width: none;
- justify-self: stretch;
- min-height: 0;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted.mode-sideboard .sideboard-shell,
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted.mode-quota .quota-shell,
- body.phone-client.device-trusted.viewport-landscape.mode-sideboard .sideboard-shell,
- body.phone-client.device-trusted.viewport-landscape.mode-quota .quota-shell {
- width: 100%;
- max-width: none;
- min-height: 0;
- }
-
- html.phone-force-landscape body.force-landscape.phone-client.device-trusted.mode-sideboard .mobile-overview,
- body.phone-client.device-trusted.viewport-landscape.mode-sideboard .mobile-overview {
- width: 100%;
- max-width: none;
- }
-
- /* Portrait phone: content scrolls under a short chrome bar. */
- body.phone-client.viewport-portrait {
- height: 100dvh;
- max-height: 100dvh;
- overflow: hidden;
- }
-
- body.phone-client.viewport-portrait header {
- gap: 8px;
- padding: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) 8px calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- }
-
- body.phone-client.viewport-portrait .brand {
- display: none;
- }
-
- body.phone-client.viewport-portrait .panel:not(.setup-panels .panel) {
- display: none;
- }
-
- body.phone-client.viewport-portrait .setup-panels {
- justify-content: center;
- }
-
- body.phone-client.viewport-portrait .top-controls {
- gap: 8px;
- }
-
- body.phone-client.viewport-portrait .view-switcher {
- width: 100%;
- }
-
- body.phone-client.viewport-portrait .view-switcher .mode-button {
- min-height: 40px;
- font-size: 14px;
- font-weight: 700;
- }
-
- body.phone-client.viewport-portrait .utility-controls {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 6px;
- }
-
- body.phone-client.viewport-portrait .utility-controls button {
- min-width: 0;
- padding-inline: 6px;
- font-size: 12px;
- }
-
- body.phone-client.viewport-portrait #rotation,
- body.phone-client.viewport-portrait:not(.eink-client) #fullscreen {
- display: none;
- }
-
- body.phone-client.viewport-portrait .utility-controls .status {
- grid-column: 1 / -1;
- min-height: 32px;
- font-size: 12px;
- }
-
- body.phone-client.viewport-portrait.display-unavailable .utility-controls .status {
- display: none;
- }
-
- body.phone-client.viewport-portrait main {
- min-height: 0;
- overflow-y: auto;
- overflow-x: hidden;
- -webkit-overflow-scrolling: touch;
- padding: 8px;
- padding-left: calc(8px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- padding-right: calc(8px + var(--safe-area-inset-right, env(safe-area-inset-right)));
- padding-bottom: calc(12px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
- align-content: start;
- }
-
- /* Display: keep 16:9 readable in portrait instead of a tiny strip. */
- body.phone-client.viewport-portrait.mode-display .display-view.active {
- width: 100%;
- align-content: start;
- justify-items: stretch;
- gap: 8px;
- }
-
- body.phone-client.viewport-portrait.mode-display .display-view.active::after {
- content: var(--i18n-portrait-hint, "直式適合看資訊板 / 額度;副螢幕建議橫向全螢幕。");
- color: var(--quiet);
- font-size: 12px;
- line-height: 1.35;
- text-align: center;
- padding: 0 8px 4px;
- }
-
- body.phone-client.viewport-portrait.mode-display .screen-media {
- width: 100%;
- max-width: none;
- height: auto;
- max-height: min(52dvh, calc((100dvw - 16px) * 9 / 16));
- aspect-ratio: 16 / 9;
- object-fit: contain;
- border-radius: 8px;
- background: #05070a;
- }
-
- /* Sideboard: true single-column phone layout (not a squashed landscape board). */
- body.phone-client.viewport-portrait .sideboard-view,
- body.phone-client.viewport-portrait .quota-view {
- width: 100%;
- max-width: none;
- justify-self: stretch;
- align-self: start;
- }
-
- body.phone-client.viewport-portrait .sideboard-shell {
- grid-template-columns: 1fr;
- grid-template-rows: auto;
- grid-auto-rows: auto;
- gap: 10px;
- min-height: 0;
- height: auto;
- overflow: visible;
- padding: 12px;
- border-radius: 12px;
- }
-
- body.phone-client.viewport-portrait .sideboard-shell::before {
- opacity: .42;
- transform: none;
- }
-
- body.phone-client.viewport-portrait .sideboard-top {
- grid-column: 1;
- align-items: start;
- }
-
- body.phone-client.viewport-portrait .side-title {
- display: grid;
- gap: 4px;
- white-space: normal;
- }
-
- body.phone-client.viewport-portrait .side-title strong {
- font-size: clamp(20px, 6.4vw, 28px);
- line-height: 1.1;
- white-space: normal;
- }
-
- body.phone-client.viewport-portrait .side-title span {
- white-space: normal;
- font-size: 13px;
- line-height: 1.35;
- }
-
- body.phone-client.viewport-portrait .side-load {
- border-right: 0;
- border-bottom: 0;
- gap: 6px;
- padding: 12px;
- border-radius: 10px;
- }
-
- body.phone-client.viewport-portrait .side-big {
- font-size: clamp(40px, 14vw, 64px);
- line-height: .92;
- }
-
- body.phone-client.viewport-portrait .side-summary {
- font-size: 13px;
- line-height: 1.35;
- }
-
- body.phone-client.viewport-portrait .metric-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- grid-template-rows: none;
- grid-auto-rows: minmax(76px, auto);
- gap: 8px;
- }
-
- body.phone-client.viewport-portrait .metric-card {
- padding: 10px;
- border-radius: 10px;
- min-height: 76px;
- }
-
- body.phone-client.viewport-portrait .metric-value {
- font-size: clamp(22px, 7vw, 30px);
- }
-
- body.phone-client.viewport-portrait .metric-sub {
- white-space: normal;
- overflow: visible;
- text-overflow: unset;
- font-size: 11px;
- }
-
- body.phone-client.viewport-portrait .side-feed {
- grid-template-rows: none;
- gap: 8px;
- padding: 12px;
- border-radius: 10px;
- overflow: visible;
- }
-
- body.phone-client.viewport-portrait .feed-list {
- overflow: visible;
- gap: 6px;
- font-size: 13px;
- line-height: 1.35;
- }
-
- body.phone-client.viewport-portrait .feed-list li {
- white-space: normal;
- overflow: visible;
- text-overflow: unset;
- }
-
- body.phone-client.viewport-portrait .quota-shell {
- min-height: 0;
- height: auto;
- overflow: visible;
- border-radius: 12px;
- padding: 12px;
- }
-
- body.phone-client.viewport-portrait .quota-top {
- grid-template-columns: 1fr;
- gap: 8px;
- align-items: start;
- }
-
- body.phone-client.viewport-portrait .quota-title {
- flex-wrap: wrap;
- white-space: normal;
- }
-
- body.phone-client.viewport-portrait .quota-account-card {
- border-radius: 10px;
- }
-
- body.ios-client.phone-client .utility-controls #keepAwake {
- display: inline-flex !important;
- }
-
- body.phone-client #keepAwake,
- body.phone-client #fullscreen {
- min-height: 36px;
- }
-
- body.phone-client #keepAwake.active {
- background: rgba(112, 217, 139, .22);
- border-color: rgba(112, 217, 139, .5);
- color: #9dffb8;
- }
-
- body.ios-client.phone-client.device-trusted header {
- gap: 6px;
- }
-
- /* Phone preview (iOS + Android): leave chrome room outside fullscreen. */
- body.phone-client.device-trusted.mode-display .display-view.active {
- width: 100%;
- height: 100%;
- min-height: 0;
- place-items: center;
- }
-
- body.phone-client.device-trusted.mode-display .screen-media {
- width: min(100%, 100dvw);
- max-height: min(72dvh, 100%);
- border-radius: 10px;
- box-shadow: 0 12px 40px rgba(0, 0, 0, .35);
- }
-
- /*
- * Fullscreen display on phone: iOS-proven geometry for Android too.
- * Must beat the non-fullscreen max-height rules above (higher specificity).
- */
- body.phone-client.device-trusted.viewer-fullscreen.mode-display .screen-media {
- width: var(--viewer-width);
- height: var(--viewer-height);
- max-width: none;
- max-height: none;
- aspect-ratio: auto;
- object-fit: contain;
- border: 0;
- border-radius: 0;
- box-shadow: none;
- }
-
- body.phone-client.device-trusted.viewer-fullscreen.mode-display .display-view.active {
- width: 100%;
- height: 100%;
- min-height: 0;
- place-items: center;
- }
-
- body.phone-client.device-trusted.mode-display .display-view.active::after {
- content: var(--i18n-touch-hint, "單指操作 PC 滑鼠 · 連點兩下放大畫面");
- color: var(--quiet);
- font-size: 12px;
- line-height: 1.35;
- text-align: center;
- padding: 6px 8px 0;
- }
-
- body.display-unavailable.phone-client.device-trusted.mode-display .display-view.active::after {
- display: none;
- }
-
- body.phone-client.viewer-fullscreen.mode-display .display-view.active::after {
- display: none;
- }
-
- .ios-home-tip {
- display: none;
- margin: 0;
- padding: 10px 12px;
- border: 1px solid rgba(143, 209, 255, .22);
- border-radius: 10px;
- background: rgba(8, 14, 20, .55);
- color: var(--muted);
- font-size: 13px;
- line-height: 1.4;
- }
-
- body.ios-client.phone-client:not(.device-trusted) .ios-home-tip,
- body.ios-client.phone-client:not(.standalone-app) .ios-home-tip.show-install {
- display: block;
- }
-
- body.ios-client.phone-client.device-trusted.standalone-app .ios-home-tip {
- display: none !important;
- }
-
- .phone-pair-rescue {
- display: none;
- margin: 0 0 10px;
- width: min(100%, 420px);
- box-sizing: border-box;
- padding: 22px 18px;
- border: 1px solid rgba(255, 210, 122, .35);
- border-radius: 12px;
- background: rgba(40, 28, 8, .72);
- color: #ffe6b0;
- gap: 12px;
- box-shadow: 0 18px 60px rgba(0, 0, 0, .3);
- }
-
- .phone-pair-rescue small {
- color: #d7af5b;
- font-size: 11px;
- font-weight: 800;
- letter-spacing: .12em;
- }
-
- body.phone-client:not(.device-trusted):not(.pc-console) .phone-pair-rescue {
- display: grid;
- }
-
- /* Keep the request action visible even while the phone client is still booting. */
- body:not(.device-trusted):not(.pc-console) .phone-pair-rescue {
- display: grid;
- }
-
- .phone-pair-rescue strong {
- color: #fff4d6;
- font-size: 24px;
- }
-
- .phone-pair-rescue > span {
- color: #f2dca9;
- line-height: 1.55;
- }
-
- .phone-pair-rescue .phone-pair-benefit {
- padding-top: 10px;
- border-top: 1px solid rgba(255, 230, 176, .2);
- color: #d6c397;
- font-size: 13px;
- }
-
- .pairing-locale-control {
- display: grid;
- grid-template-columns: auto minmax(0, 1fr);
- align-items: center;
- gap: 12px;
- color: #f2dca9;
- font-weight: 800;
- }
-
- .pairing-locale-control select {
- min-width: 0;
- min-height: 46px;
- padding: 8px 12px;
- border: 2px solid rgba(255, 255, 255, .34);
- border-radius: 8px;
- background: #fffdf4;
- color: #171714;
- font: inherit;
- font-size: 17px;
- }
-
- body.eink-client .pairing-locale-control select {
- min-height: 52px;
- border: 3px solid #171714;
- border-radius: 3px;
- font-size: 20px;
- }
-
- .phone-pair-rescue input,
- .phone-pair-rescue textarea {
- width: 100%;
- min-height: 44px;
- box-sizing: border-box;
- border: 1px solid rgba(255,255,255,.18);
- border-radius: 8px;
- background: rgba(0,0,0,.28);
- color: #fff;
- padding: 10px;
- font-size: 14px;
- }
-
- .phone-pair-rescue button {
- min-height: 50px;
- border: 0;
- border-radius: 10px;
- background: #70d98b;
- color: #0a1311;
- font-weight: 800;
- }
-
- .pair-success-banner {
- display: none;
- position: sticky;
- top: 0;
- z-index: 40;
- margin: 0 0 10px;
- padding: 14px 12px;
- border-radius: 12px;
- border: 1px solid rgba(112, 217, 139, .45);
- background: rgba(12, 36, 24, .94);
- color: #d8ffe4;
- font-size: 14px;
- line-height: 1.4;
- }
-
- .pair-success-banner.show {
- display: grid;
- gap: 8px;
- }
-
- .pair-success-banner strong {
- font-size: 18px;
- color: #8dffb0;
- }
-
- /* Single full-screen path for iPhone on HTTP: must switch to HTTPS. */
- #mobileHttpsGate {
- display: none;
- position: fixed;
- inset: 0;
- z-index: 1000;
- padding: calc(24px + var(--safe-area-inset-top, env(safe-area-inset-top))) 20px calc(24px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom)));
- background: #0d131a;
- color: #f5f7fb;
- overflow: auto;
- }
-
- body.mobile-http-blocked #mobileHttpsGate {
- display: grid;
- align-content: center;
- gap: 14px;
- max-width: 100%;
- }
-
- body.mobile-http-blocked > header,
- body.mobile-http-blocked > main,
- body.mobile-http-blocked > .exit-viewer {
- display: none !important;
- }
-
- #mobileHttpsGate h1 {
- margin: 0;
- font-size: 26px;
- line-height: 1.15;
- }
-
- #mobileHttpsGate p {
- margin: 0;
- color: #b9c4d0;
- font-size: 15px;
- line-height: 1.45;
- }
-
- #mobileHttpsGate ol {
- margin: 0;
- padding-left: 1.2em;
- color: #dce7f4;
- line-height: 1.5;
- }
-
- #mobileHttpsGate .gate-actions {
- display: grid;
- gap: 10px;
- margin-top: 8px;
- }
-
- #mobileHttpsGate a,
- #mobileHttpsGate button {
- display: grid;
- place-items: center;
- min-height: 48px;
- border-radius: 10px;
- border: 0;
- font-size: 16px;
- font-weight: 800;
- text-decoration: none;
- }
-
- #mobileHttpsGate .gate-primary {
- background: #70d98b;
- color: #0a1311;
- }
-
- #mobileHttpsGate .gate-secondary {
- background: #223040;
- color: #f5f7fb;
- border: 1px solid rgba(143, 209, 255, .22);
- }
-
- #mobileHttpsGate .gate-note {
- font-size: 13px;
- color: #7f91a5;
- }
-
- @media (max-width: 760px) {
- header {
- grid-template-columns: 1fr;
- gap: 12px;
- padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(12px + var(--safe-area-inset-right, env(safe-area-inset-right))) 12px calc(12px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- }
-
- .brand-mark {
- width: 34px;
- height: 34px;
- font-size: 17px;
- }
-
- .brand-copy strong {
- font-size: 18px;
- }
-
- .top-controls {
- grid-template-columns: 1fr;
- width: 100%;
- justify-content: stretch;
- }
-
- .view-switcher {
- display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- width: 100%;
- }
-
- .view-switcher .mode-button {
- width: 100%;
- }
-
- .utility-controls {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- width: 100%;
- }
-
- .utility-controls select,
- .utility-controls button {
- width: 100%;
- }
-
- .utility-controls .status {
- grid-column: 1 / -1;
- justify-content: center;
- }
-
- .panel .controls {
- display: grid;
- grid-template-columns: 1fr 1fr;
- }
-
- .panel select,
- .panel button,
- .connect-panel {
- grid-column: 1 / -1;
- }
-
- .connect-panel {
- grid-template-columns: 1fr;
- gap: 12px;
- padding: 12px;
- }
-
- .connect-qr {
- grid-template-columns: 118px minmax(0, 1fr);
- grid-template-rows: auto;
- align-items: center;
- justify-items: start;
- padding: 10px;
- }
-
- .connect-panel img {
- max-width: 118px;
- padding: 6px;
- }
-
- .connect-qr span {
- text-align: left;
- font-size: 13px;
- }
-
- .connect-title strong {
- font-size: 20px;
- }
-
- .pairing-guide {
- grid-template-columns: 1fr;
- gap: 7px;
- }
-
- .pairing-step {
- min-height: 0;
- padding: 10px;
- }
-
- .connect-actions {
- grid-template-columns: 1fr;
- }
-
- .connect-actions a,
- .connect-actions button {
- width: 100%;
- min-height: 44px;
- }
-
- .connect-status {
- grid-template-columns: 1fr;
- }
-
- input {
- width: 100%;
- box-sizing: border-box;
- }
-
- .sideboard-shell {
- grid-template-columns: 1fr;
- grid-template-rows: auto auto auto auto;
- }
-
- .quota-grid {
- grid-template-columns: 1fr;
- }
- }
-
- @media (max-width: 420px) {
- .connect-qr {
- grid-template-columns: 1fr;
- justify-items: center;
- }
-
- .connect-qr span {
- text-align: center;
- }
- }
-
-.pending-pairings {
- display: grid;
- gap: 8px;
- margin-bottom: 12px;
- padding: 12px;
- border: 2px solid var(--green);
- background: rgba(112, 217, 139, .1);
-}
-
-.pending-pairings[hidden] {
- display: none !important;
-}
-
-.pending-pairing-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto auto;
- gap: 8px;
- align-items: center;
- padding: 10px;
- background: var(--surface-2);
-}
-
-.pending-pairing-row > div {
- display: grid;
- gap: 3px;
-}
-
-.pending-pairing-row span,
-.pending-pairing-row b {
- color: var(--muted);
- font-size: 12px;
-}
-
-.pending-pairing-row [data-pair-approve] {
- background: var(--green);
- color: #071015;
-}
-
-body.pc-console .connect-panel {
- grid-template-columns: 1fr;
-}
-
-/* PC 本機預設精簡連線面板;一旦在配對流程就必須顯示 QR / 狀態 */
-body.pc-console:not(.pairing-active) .connect-qr,
-body.pc-console:not(.pairing-active) .pairing-guide,
-body.pc-console:not(.pairing-active) .connect-status,
-body.pc-console:not(.pairing-active) #httpsCertLink {
- display: none !important;
-}
-
-body.pc-console.pairing-active .connect-panel {
- grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
- gap: 16px;
- align-items: start;
-}
-
-body.pc-console.pairing-active .connect-qr {
- display: grid !important;
- visibility: visible !important;
- opacity: 1 !important;
-}
-
-body.pc-console.pairing-active .connect-qr img,
-body.pc-console.pairing-active #qrCode {
- display: block !important;
- visibility: visible !important;
- width: 100%;
- max-width: 252px;
- aspect-ratio: 1;
- background: #fff;
-}
-
-body.pc-console.pairing-active .pairing-guide,
-body.pc-console.pairing-active .connect-status {
- display: grid !important;
-}
-
-/* Cert install stays inside advanced connect details — do not promote it onto
- the healthy pairing card. Failure path reuses .pairing-needs-advanced below. */
-
-body.trusted-public-endpoint #certificateSetupTitle,
-body.trusted-public-endpoint #httpsCertLink,
-body.trusted-public-endpoint #androidCertLink,
-body.trusted-public-endpoint #androidCertHelp {
- display: none !important;
-}
-
-@media (max-width: 720px) {
- .public-endpoint-controls {
- grid-template-columns: 1fr 1fr;
- }
-
- .public-endpoint-controls input {
- grid-column: 1 / -1;
- }
-}
-
-/* Healthy pairing: hide advanced connect entirely (summary row included). */
-body.pc-console.pairing-active:not(.pairing-needs-advanced) .connect-links {
- display: none !important;
-}
-
-/* Endpoint repair during pairing: keep advanced visible and mark the surface. */
-body.pc-console.pairing-active.pairing-needs-advanced .connect-links {
- display: block !important;
- border-color: var(--yellow);
-}
-
-body.pc-console.pairing-active.pairing-needs-advanced .connect-links[open] {
- border-color: var(--yellow);
-}
-
-body.pc-console .connect-copy {
- gap: 12px;
-}
-
-body.pc-console .connect-title strong {
- font-size: 24px;
-}
-
-body.pc-console .connect-title span {
- font-size: 15px;
-}
-
-body.pc-console:not(.deck-window) #einkModeToggle,
-body.pc-console:not(.deck-window) #keepAwake,
-body.pc-console:not(.deck-window) #fullscreen {
- display: none !important;
-}
-
-body:not(.device-trusted):not(.pc-console) .display-empty-actions {
- display: none !important;
-}
-
-/* Phone cannot install the virtual display — only Sideboard/Quota escape hatches. */
-body.phone-client:not(.pc-console) #installVirtualDisplay {
- display: none !important;
-}
-
-body.phone-client:not(.pc-console) #openSideboardFromEmpty {
- min-width: min(100%, 280px);
-}
-
-/* PC keeps Device setup as the primary empty-state action. */
-body.pc-console .display-empty-actions #installVirtualDisplay {
- order: -1;
-}
-
-body.phone-client:not(.eink-client) .quota-toolbox {
- justify-self: stretch;
- display: flex;
- flex-wrap: wrap;
- border-radius: 8px;
-}
-
-body.phone-client:not(.eink-client) .quota-toolbox button {
- flex: 1 1 calc(50% - 4px);
- min-width: min(128px, 100%);
- min-height: 40px;
- padding: 7px 10px;
- font-size: 12px;
- line-height: 1.25;
- white-space: normal;
-}
-
-/* Final E Ink legibility pass. Keep this block last so phone/skin rules cannot
- * reintroduce low-resolution color text or narrow display fonts. */
-body.eink-client,
-body.eink-client button,
-body.eink-client select,
-body.eink-client input {
- font-family: "Noto Sans TC", "Microsoft JhengHei", system-ui, sans-serif !important;
-}
-
-body.eink-client .sideboard-shell,
-body.eink-client .quota-shell {
- --side-line: #000;
- --side-panel: #fffef8;
- --side-accent: #000;
- --side-accent-2: #000;
- --quota-ink: #000;
- --quota-muted: #222;
- --quota-panel: #fffef8;
- color: #000 !important;
- background: #f5f2e7 !important;
- font-size: 18px;
-}
-
-/* Dedicated phone dashboard. Desktop and e-ink keep the configurable grid. */
-.mobile-overview {
- display: none;
-}
-
-.mobile-detail-panel[hidden],
-.mobile-overview [data-mobile-section][hidden] {
- display: none !important;
-}
-
-@media (max-width: 960px) {
- body.phone-client:not(.eink-client).mode-sideboard .sideboard-top,
- body.phone-client:not(.eink-client).mode-sideboard #dashboardEditBar,
- body.phone-client:not(.eink-client).mode-sideboard #systemSideboardPage,
- body.phone-client:not(.eink-client).mode-sideboard #customSideboardPage {
- display: none !important;
- }
-
- /* Fullscreen is a mode-level action, not a landscape-only action. Keep it
- * reachable from Display and Quota in portrait too; both views use the same
- * header control. The !important intentionally overrides the older portrait
- * compaction rule above. */
- body.phone-client.device-trusted:not(.eink-client) .utility-controls #fullscreen {
- display: inline-flex !important;
- }
-
- body.phone-client:not(.eink-client).mode-sideboard .sideboard-shell {
- display: block;
- height: auto;
- min-height: 0;
- padding: 0;
- overflow: visible;
- }
-
- body.phone-client:not(.eink-client).mode-sideboard .mobile-overview {
- display: flex;
- flex-direction: column;
- gap: 8px;
- min-width: 0;
- width: 100%;
- color: var(--side-text, #edf7f5);
- }
-
- .mobile-overview button {
- min-height: 38px;
- border: 1px solid var(--side-line, rgba(151, 225, 210, .24));
- border-radius: 10px;
- color: inherit;
- background: rgba(11, 27, 32, .88);
- font: inherit;
- }
-
- .mobile-overview-head,
- .mobile-section-heading,
- .mobile-overview-actions,
- .mobile-quota-strip {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- }
-
- .mobile-overview-head {
- min-height: 42px;
- padding: 2px 2px 0;
- }
-
- .mobile-overview-head > div:first-child {
- display: grid;
- gap: 1px;
- }
-
- .mobile-overview-head strong {
- font-size: 19px;
- line-height: 1.1;
- }
-
- #mobileConnectionState {
- color: var(--side-muted, #9eb8b4);
- font-size: 14px;
- }
-
- #mobileConnectionState.online {
- color: var(--side-good, #80e8bd);
- }
-
- .mobile-overview-actions button,
- .mobile-section-heading button {
- min-height: 36px;
- padding: 6px 10px;
- font-size: 14px;
- font-weight: 700;
- }
-
- .mobile-overview-section,
- .mobile-detail-block,
- .mobile-detail-host,
- .mobile-detail-metrics article {
- border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
- border-radius: 12px;
- background: linear-gradient(145deg, rgba(14, 34, 40, .96), rgba(7, 20, 25, .96));
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, .035);
- }
-
- .mobile-system-card {
- display: grid;
- gap: 3px;
- min-height: 76px;
- padding: 9px 11px;
- }
-
- .mobile-section-heading {
- min-height: 28px;
- color: var(--side-muted, #a5bbb8);
- font-size: 14px;
- }
-
- #mobileSystemStatus {
- font-size: 21px;
- line-height: 1.12;
- }
-
- #mobileSystemReason {
- overflow: hidden;
- color: var(--side-muted, #a5bbb8);
- font-size: 14px;
- line-height: 1.25;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .mobile-core-metrics {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 7px;
- border: 0;
- background: none;
- box-shadow: none;
- }
-
- .mobile-metric-card {
- display: grid;
- grid-template-columns: 1fr auto;
- align-items: center;
- gap: 4px 8px;
- min-width: 0;
- min-height: 64px;
- padding: 8px 10px;
- border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
- border-radius: 11px;
- background: rgba(8, 24, 29, .92);
- }
-
- .mobile-metric-card > span {
- color: var(--side-muted, #a5bbb8);
- font-size: 15px;
- font-weight: 700;
- }
-
- .mobile-metric-card > strong {
- font-size: 23px;
- line-height: 1;
- }
-
- .mobile-mini-bar {
- grid-column: 1 / -1;
- height: 5px;
- overflow: hidden;
- border-radius: 999px;
- background: rgba(255, 255, 255, .1);
- }
-
- .mobile-mini-bar > span {
- display: block;
- width: 0;
- height: 100%;
- border-radius: inherit;
- background: var(--side-accent, #63ddc7);
- transition: width .2s ease;
- }
-
- .mobile-activity-preview {
- min-height: 108px;
- padding: 8px 10px;
- overflow: hidden;
- }
-
- #mobileActivityPreview,
- .mobile-activity-list,
- #mobileProcessList {
- margin: 0;
- padding: 0;
- list-style: none;
- }
-
- #mobileActivityPreview {
- display: grid;
- gap: 5px;
- margin-top: 4px;
- }
-
- #mobileActivityPreview > li {
- min-width: 0;
- max-height: 62px;
- padding: 5px 7px;
- overflow: hidden;
- border-radius: 8px;
- background: rgba(255, 255, 255, .045);
- font-size: 14px;
- line-height: 1.25;
- }
-
- #mobileActivityPreview .activity-feed-body,
- #mobileActivityPreview .activity-feed-message,
- #mobileActivityPreview .activity-feed-text {
- display: -webkit-box;
- overflow: hidden;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 1;
- }
-
- #mobileActivityPreview .activity-feed-meta {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
-
- .mobile-quota-strip {
- width: 100%;
- min-height: 64px;
- padding: 8px 11px;
- text-align: left;
- }
-
- .mobile-quota-strip > span {
- display: grid;
- gap: 2px;
- }
-
- .mobile-quota-strip > span:last-child {
- justify-items: end;
- text-align: right;
- }
-
- .mobile-quota-strip small {
- color: var(--side-muted, #a5bbb8);
- font-size: 13px;
- }
-
- #mobileQuotaValue {
- font-size: 25px;
- }
-
- #mobileQuotaReset {
- font-size: 14px;
- }
-
- .mobile-detail-panel {
- position: fixed;
- inset: 0;
- z-index: 2200;
- display: flex;
- flex-direction: column;
- gap: 10px;
- padding: calc(10px + var(--safe-area-inset-top, env(safe-area-inset-top))) calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right))) calc(12px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom))) calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left)));
- overflow-y: auto;
- color: #edf7f5;
- background: #071116;
- overscroll-behavior: contain;
- }
-
- .mobile-detail-header {
- position: sticky;
- top: calc(-10px - var(--safe-area-inset-top, env(safe-area-inset-top)));
- z-index: 2;
- display: grid;
- grid-template-columns: minmax(76px, 1fr) auto minmax(76px, 1fr);
- align-items: center;
- min-height: 48px;
- padding: 4px 0;
- background: #071116;
- }
-
- .mobile-detail-header > strong {
- font-size: 19px;
- text-align: center;
- }
-
- .mobile-detail-header > button:first-child {
- justify-self: start;
- }
-
- .mobile-detail-header > button:last-child {
- justify-self: end;
- }
-
- .mobile-activity-filters {
- display: grid;
- grid-template-columns: repeat(3, 1fr);
- gap: 7px;
- }
-
- .mobile-activity-filters button.active {
- border-color: var(--side-accent, #63ddc7);
- color: #06110f;
- background: var(--side-accent, #63ddc7);
- }
-
- .mobile-activity-list {
- display: grid;
- gap: 8px;
- min-height: 0;
- overflow-y: auto;
- scrollbar-gutter: stable;
- }
-
- .mobile-activity-list > li {
- padding: 10px;
- border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
- border-radius: 10px;
- background: rgba(12, 29, 35, .9);
- font-size: 15px;
- line-height: 1.4;
- }
-
- .mobile-detail-host,
- .mobile-detail-block {
- display: grid;
- gap: 6px;
- padding: 11px;
- font-size: 15px;
- }
-
- .mobile-detail-metrics {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 8px;
- }
-
- .mobile-detail-metrics article {
- display: grid;
- gap: 3px;
- min-width: 0;
- padding: 11px;
- }
-
- .mobile-detail-metrics strong {
- font-size: 21px;
- }
-
- .mobile-detail-metrics small,
- .mobile-detail-block > span {
- color: var(--side-muted, #a5bbb8);
- font-size: 14px;
- }
-
- #mobileProcessList,
- #mobileCustomCards {
- display: grid;
- gap: 6px;
- }
-
- #mobileProcessList > li,
- #mobileCustomCards > article {
- padding: 7px 8px;
- border-radius: 8px;
- background: rgba(255, 255, 255, .045);
- }
-
- .mobile-layout-list {
- display: grid;
- gap: 8px;
- }
-
- .mobile-layout-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- min-height: 52px;
- padding: 8px 10px;
- border: 1px solid var(--side-line, rgba(151, 225, 210, .22));
- border-radius: 10px;
- background: rgba(12, 29, 35, .9);
- }
-
- .mobile-layout-row label {
- display: flex;
- align-items: center;
- gap: 10px;
- font-size: 16px;
- }
-
- .mobile-layout-row > div {
- display: flex;
- gap: 6px;
- }
-
- .mobile-layout-row button {
- min-width: 42px;
- }
-
- .mobile-layout-reset {
- margin-top: auto;
- }
-
- body.mobile-detail-open {
- overflow: hidden !important;
- }
-}
-
-/* BOOX connection state: readable at a glance and never confused with a
- remote-display warning (e-readers do not use the virtual display). */
-body.eink-client .status {
- min-height: 48px;
- padding: 8px 14px !important;
- border: 3px solid #000 !important;
- border-radius: 4px !important;
- background: #fffef8 !important;
- color: #000 !important;
- font-size: clamp(22px, 4vw, 32px) !important;
- font-weight: 900 !important;
- letter-spacing: .04em;
- box-shadow: none !important;
-}
-
-body.eink-client .status .dot,
-body.eink-client .status .dot.online {
- width: 16px;
- height: 16px;
- border: 2px solid #000;
- background: #000 !important;
- box-shadow: none !important;
-}
-
-.eink-connection-state {
- display: none;
-}
-
-body.eink-client .eink-connection-state {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- min-height: 44px;
- padding: 4px 12px;
- border: 3px solid #000;
- border-radius: 4px;
- background: #000;
- color: #fffef8;
- font-size: clamp(22px, 3.8vw, 32px);
- font-weight: 900;
- line-height: 1;
- letter-spacing: .04em;
- white-space: nowrap;
-}
-
-body.eink-client .eink-connection-state.offline {
- background: #fffef8;
- color: #000;
-}
-
-/* E Ink clients still need explicit escape hatches from the dense panel.
- The generic trusted-phone rule hides the utility row; keep the mode toggle
- available so an accidentally enabled paper layout can always be disabled. */
-body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls {
- display: flex !important;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
-}
-
-body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls > * {
- display: none !important;
-}
-
-body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls #einkModeToggle,
-body.eink-client.phone-client.device-trusted:not(.mode-display) .utility-controls #fullscreen {
- display: inline-flex !important;
- align-items: center;
- justify-content: center;
- min-height: 44px;
- padding: 6px 12px;
- border: 3px solid #000;
- border-radius: 3px;
- background: #fffef8;
- color: #000;
- font-size: 20px;
- font-weight: 900;
-}
-
-body.eink-client .quota-top {
- grid-template-columns: minmax(0, 1fr) auto auto;
- align-items: center;
-}
-
-body.eink-client.viewport-portrait .quota-top {
- grid-template-columns: minmax(0, 1fr) auto;
-}
-
-body.eink-client.viewport-portrait .quota-left {
- grid-column: 1 / -1;
-}
-
-body.eink-client .skin-picker {
- display: none !important;
-}
-
-body.eink-client .sideboard-shell {
- align-content: start;
- grid-template-columns: 1fr !important;
- grid-template-rows: auto !important;
- grid-auto-rows: max-content !important;
- gap: 14px !important;
- padding: 16px 14px 28px !important;
- min-height: 0 !important;
- height: auto !important;
- overflow: visible !important;
-}
-
-/* Title + page tabs share one header row (name left, tabs right). */
-body.eink-client .sideboard-top {
- grid-column: 1 / -1 !important;
- display: flex !important;
- flex-direction: row;
- flex-wrap: nowrap;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-width: 0 !important;
- max-width: 100% !important;
- min-height: 0;
- padding: 4px 0 12px !important;
- border-bottom: 3px solid #24241f !important;
- background: transparent;
- overflow: visible !important;
-}
-
-body.eink-client .sideboard-page-tabs {
- display: flex !important;
- flex: 0 0 auto;
- flex-wrap: nowrap;
- align-items: center;
- gap: 8px !important;
- min-width: 0 !important;
- max-width: none !important;
- margin: 0 !important;
-}
-
-body.eink-client .side-load,
-body.eink-client .metric-grid,
-body.eink-client .side-feed,
-body.eink-client .custom-sideboard-page {
- grid-column: 1 / -1 !important;
- min-width: 0 !important;
- max-width: 100% !important;
-}
-
-body.eink-client .side-title {
- display: grid;
- flex: 1 1 auto;
- gap: 4px;
- white-space: normal;
- min-width: 0;
- overflow: visible !important;
-}
-
-body.eink-client .side-title strong {
- color: #000 !important;
- font-size: clamp(24px, 5.8vw, 36px) !important;
- line-height: 1.2 !important;
- white-space: normal;
- overflow: visible !important;
- overflow-wrap: anywhere;
- /* Keep glyph tops from being clipped by parent overflow/fullscreen edges. */
- padding-top: 2px;
-}
-
-body.eink-client .side-title span {
- line-height: 1.35 !important;
-}
-
-/* Fullscreen e-ink panel follows the real safe area. The earlier fixed 96px
- workaround created a large blank forehead in BOOX's installed PWA. */
-body.eink-client.dashboard-viewer,
-body.eink-client.viewer-immersive {
- overflow-x: hidden !important;
- overflow-y: auto !important;
- -webkit-overflow-scrolling: touch;
- padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) !important;
- padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px));
- padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px));
- padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
- box-sizing: border-box !important;
-}
-
-body.eink-client.dashboard-viewer .sideboard-shell,
-body.eink-client.viewer-immersive .sideboard-shell,
-body.eink-client.viewer-fullscreen.mode-sideboard .sideboard-shell {
- padding-top: 12px !important;
- padding-left: 14px !important;
- padding-right: 14px !important;
- padding-bottom: 28px !important;
-}
-
-body.eink-client.dashboard-viewer .sideboard-top,
-body.eink-client.viewer-immersive .sideboard-top {
- /* BOOX Chromium collapses this flex item to its border/padding contribution
- inside the fullscreen grid. Its children then center above the grid track
- and end up underneath the browser's 96px fullscreen strip. */
- min-height: 72px !important;
- padding-top: 6px !important;
-}
-
-body.eink-client.dashboard-viewer .side-title strong,
-body.eink-client.viewer-immersive .side-title strong {
- line-height: 1.25 !important;
- padding-top: 0 !important;
-}
-
-body.eink-client .side-title span,
-body.eink-client .side-summary,
-body.eink-client .side-summary span,
-body.eink-client .metric-sub,
-body.eink-client .feed-list,
-body.eink-client .feed-list li,
-body.eink-client .feed-list span,
-body.eink-client .quota-title span,
-body.eink-client .quota-status,
-body.eink-client .quota-window span,
-body.eink-client .quota-window small,
-body.eink-client .quota-time,
-body.eink-client .quota-action-status {
- color: #000 !important;
- font-size: 18px !important;
- line-height: 1.45 !important;
- text-shadow: none !important;
-}
-
-body.eink-client .side-panel,
-body.eink-client .metric-card,
-body.eink-client .quota-account-card,
-body.eink-client .quota-local-card {
- background: #fffef8 !important;
- border: 3px solid #000 !important;
- border-radius: 4px !important;
- backdrop-filter: none !important;
- -webkit-backdrop-filter: none !important;
-}
-
-body.eink-client .side-load {
- display: grid !important;
- align-content: start !important;
- gap: 8px !important;
- min-height: 0 !important;
- padding: 16px !important;
- overflow: visible !important;
-}
-
-body.eink-client .side-kicker,
-body.eink-client .feed-heading,
-body.eink-client .quota-provider-title {
- color: #000 !important;
- font-size: 15px !important;
- font-weight: 800 !important;
- letter-spacing: .02em;
-}
-
-body.eink-client .side-big {
- color: #000 !important;
- font-family: inherit !important;
- font-size: clamp(52px, 14vw, 72px) !important;
- line-height: .95 !important;
- overflow: visible !important;
-}
-
-body.eink-client .side-summary {
- overflow: visible !important;
- white-space: normal !important;
-}
-
-body.eink-client .side-summary span {
- white-space: normal !important;
- overflow: visible !important;
- text-overflow: unset !important;
-}
-
-body.eink-client .metric-grid {
- display: grid !important;
- grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
- grid-template-rows: none !important;
- grid-auto-rows: minmax(108px, auto) !important;
- gap: 10px !important;
- min-height: 0 !important;
- overflow: visible !important;
-}
-
-body.eink-client .metric-card {
- min-height: 108px !important;
- height: auto !important;
- padding: 12px !important;
- gap: 6px 10px !important;
- overflow: visible !important;
- align-content: start;
-}
-
-body.eink-client .metric-value,
-body.eink-client .quota-window b {
- color: #000 !important;
- font-family: inherit !important;
- font-size: clamp(28px, 7vw, 40px) !important;
- font-variant-numeric: tabular-nums;
- letter-spacing: -.03em;
- white-space: nowrap;
-}
-
-body.eink-client .metric-sub {
- white-space: normal !important;
- overflow: visible !important;
- text-overflow: unset !important;
- font-size: 14px !important;
- line-height: 1.3 !important;
-}
-
-body.eink-client .mini-bar,
-body.eink-client .quota-bar {
- height: 14px !important;
- border: 2px solid #000 !important;
- background: #e8e3d3 !important;
-}
-
-body.eink-client .mini-bar span,
-body.eink-client .quota-bar i {
- background: #000 !important;
- transition: none !important;
-}
-
-body.eink-client .side-feed {
- display: grid !important;
- grid-template-rows: none !important;
- align-content: start;
- gap: 10px !important;
- padding: 16px !important;
- overflow: visible !important;
- min-height: 0 !important;
-}
-
-body.eink-client .feed-list {
- gap: 8px !important;
- overflow: visible !important;
- min-height: 0 !important;
- font-size: 16px !important;
- line-height: 1.4 !important;
-}
-
-body.eink-client .feed-list li {
- padding: 8px 0 !important;
- border: 0 !important;
- border-bottom: 1px solid #777 !important;
- background: transparent !important;
- white-space: normal !important;
- overflow: visible !important;
- text-overflow: unset !important;
-}
-
-body.eink-client .quota-title strong,
-body.eink-client .quota-account-email {
- color: #000 !important;
- font-family: inherit !important;
-}
-
-/* Unified editable dashboard -------------------------------------------------
- System metrics and custom cards share one persisted 12-column canvas. */
-.dashboard-edit-toggle {
- min-height: 34px;
- padding: 6px 12px;
- border: 1px solid var(--side-line);
- border-radius: 4px;
- background: rgba(255,255,255,.08);
- color: inherit;
- font-weight: 700;
-}
-
-.dashboard-edit-toggle[aria-pressed="true"] {
- background: var(--side-accent);
- color: #071015;
- border-color: var(--side-accent);
-}
-
-.dashboard-edit-bar {
- grid-column: 1 / -1;
- display: grid;
- grid-template-columns: minmax(180px, 1fr) auto;
- align-items: center;
- gap: 8px 14px;
- padding: 8px 10px;
- border: 1px solid var(--side-line);
- background: rgba(7, 16, 21, .96);
- z-index: 30 !important;
-}
-
-.dashboard-edit-bar[hidden] { display: none !important; }
-
-.dashboard-edit-copy {
- display: grid;
- gap: 2px;
-}
-
-.dashboard-edit-copy strong { color: var(--side-accent); }
-.dashboard-edit-copy span { color: rgba(247,242,232,.72); font-size: 12px; }
-.dashboard-edit-status.error { color: var(--side-warn); }
-
-.dashboard-selection-controls {
- grid-column: 1 / -1;
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 6px;
- padding-bottom: 8px;
- border-bottom: 1px solid var(--side-line);
-}
-
-.dashboard-selection-controls[hidden] { display: none !important; }
-
-.dashboard-selection-controls strong {
- min-width: min(180px, 100%);
- margin-right: auto;
- color: var(--side-accent);
-}
-
-.dashboard-selection-controls button {
- min-width: 42px;
- min-height: 34px;
- padding: 5px 9px;
- border: 1px solid rgba(255,255,255,.3);
- border-radius: 3px;
- background: #162229;
- color: #fff;
- touch-action: none;
-}
-
-.dashboard-edit-actions,
-.dashboard-card-library {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- align-items: center;
-}
-
-.dashboard-edit-actions button,
-.dashboard-card-library button {
- min-height: 32px;
- padding: 5px 9px;
- border-radius: 4px;
- background: rgba(255,255,255,.08);
- color: inherit;
- border-color: var(--side-line);
-}
-
-.dashboard-card-library {
- grid-column: 1 / -1;
- padding-top: 8px;
- border-top: 1px solid var(--side-line);
-}
-
-.dashboard-card-library[hidden] { display: none !important; }
-.dashboard-library-empty { color: rgba(247,242,232,.62); font-size: 12px; }
-
-#systemSideboardPage.dashboard-grid {
- display: grid;
- grid-column: 1 / -1;
- grid-template-columns: repeat(12, minmax(0, 1fr));
- grid-template-rows: repeat(6, minmax(0, 1fr));
- gap: 8px;
- min-width: 0;
- min-height: 680px;
- isolation: isolate;
-}
-
-/* Desktop control and deck windows are one-screen dashboards. The previous
- 680px minimum made a short PC window grow with the notification feed even
- though the feed itself was scrollable. Constrain the dashboard to the
- available viewport and let individual cards own their scrolling. */
-body.pc-console.mode-sideboard {
- height: 100dvh;
- min-height: 0;
- overflow: hidden;
-}
-
-body.pc-console.mode-sideboard main {
- min-height: 0;
- overflow: hidden;
-}
-
-body.pc-console.mode-sideboard .sideboard-view.active {
- align-self: stretch;
- height: 100%;
- min-height: 0;
-}
-
-body.pc-console.mode-sideboard .sideboard-shell {
- height: 100%;
- min-height: 0;
-}
-
-body.pc-console.mode-sideboard #systemSideboardPage.dashboard-grid,
-body.deck-window.mode-sideboard #systemSideboardPage.dashboard-grid {
- height: 100%;
- min-height: 0;
- overflow: hidden;
-}
-
-#systemSideboardPage.dashboard-grid > .metric-grid,
-#systemSideboardPage.dashboard-grid > .custom-cards-grid {
- display: contents !important;
-}
-
-#systemSideboardPage.dashboard-grid [data-dashboard-key] {
- position: relative;
- z-index: 1;
- min-width: 0;
- min-height: 0;
- height: auto;
- overflow: hidden;
- box-sizing: border-box;
-}
-
-#systemSideboardPage.dashboard-grid .dashboard-card-hidden {
- display: none !important;
-}
-
-.dashboard-feed-card {
- grid-template-rows: auto minmax(0, 1fr);
-}
-
-.dashboard-card-editor {
- position: absolute !important;
- inset: 6px 6px auto auto;
- z-index: 50 !important;
- display: flex;
- gap: 4px;
- padding: 4px;
- border: 1px solid var(--side-accent);
- background: #071015;
- box-shadow: 0 3px 12px rgba(0,0,0,.45);
-}
-
-.dashboard-card-editor button {
- min-width: 30px;
- min-height: 30px;
- padding: 3px 7px;
- border: 1px solid rgba(255,255,255,.3);
- border-radius: 2px;
- background: #162229;
- color: #fff;
- font-size: 11px;
- touch-action: none;
-}
-
-.dashboard-card-editor .dashboard-drag-handle {
- cursor: grab;
- color: var(--side-accent);
-}
-
-.dashboard-card-dragging {
- z-index: 40 !important;
- opacity: .78;
- outline: 3px solid var(--side-accent);
- pointer-events: none;
-}
-
-.dashboard-editing #systemSideboardPage.dashboard-grid {
- background-image:
- linear-gradient(to right, rgba(255,255,255,.055) 1px, transparent 1px),
- linear-gradient(to bottom, rgba(255,255,255,.055) 1px, transparent 1px);
- background-size: calc(100% / 12) calc(100% / 6);
- outline: 1px dashed rgba(255,255,255,.2);
-}
-
-.dashboard-editing #systemSideboardPage.dashboard-grid [data-dashboard-key] {
- cursor: pointer;
-}
-
-.dashboard-editing #systemSideboardPage.dashboard-grid .dashboard-card-selected {
- cursor: grab;
- touch-action: none;
-}
-
-.dashboard-editing #systemSideboardPage.dashboard-grid .dashboard-card-selected {
- z-index: 2;
- outline: 3px solid var(--side-accent);
- outline-offset: -3px;
-}
-
-.dashboard-config-panel {
- position: absolute !important;
- inset: 76px 12px 12px;
- z-index: 60 !important;
- padding: 12px;
- border: 1px solid var(--side-line);
- background: rgba(7, 16, 21, .985);
- box-shadow: 0 18px 60px rgba(0,0,0,.62);
-}
-
-body.eink-client .sideboard-shell {
- grid-template-columns: 1fr !important;
- grid-template-rows: auto minmax(0, 1fr) !important;
- grid-auto-rows: unset !important;
- gap: 8px !important;
- height: var(--viewer-height, 100dvh) !important;
- min-height: var(--viewer-height, 100dvh) !important;
- padding: 10px 14px 12px !important;
- overflow: hidden !important;
-}
-
-body.eink-client .sideboard-shell.dashboard-editing {
- grid-template-rows: auto auto minmax(0, 1fr) !important;
-}
-
-body.eink-client .sideboard-top {
- min-height: 0 !important;
- height: auto !important;
- padding: 2px 0 8px !important;
- border-bottom-width: 2px !important;
-}
-
-body.eink-client.dashboard-viewer .sideboard-top,
-body.eink-client.viewer-immersive .sideboard-top {
- min-height: 0 !important;
- padding-top: 2px !important;
-}
-
-body.eink-client .side-title strong {
- font-size: clamp(24px, 4vw, 34px) !important;
- line-height: 1.1 !important;
-}
-
-body.eink-client .side-title span {
- font-size: 17px !important;
- line-height: 1.2 !important;
-}
-
-body.eink-client .dashboard-edit-toggle {
- flex: 0 0 auto;
- min-height: 46px;
- padding: 6px 14px;
- border: 3px solid #000;
- background: #fffef8;
- color: #000;
- font-size: 17px;
-}
-
-body.eink-client .dashboard-edit-toggle[aria-pressed="true"] {
- background: #000;
- color: #fffef8;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid {
- display: grid !important;
- grid-column: 1 / -1 !important;
- grid-template-columns: repeat(12, minmax(0, 1fr)) !important;
- grid-template-rows: repeat(6, minmax(0, 1fr)) !important;
- grid-auto-rows: unset !important;
- gap: 8px !important;
- min-height: 0 !important;
- height: 100% !important;
- overflow: hidden !important;
-}
-
-body.eink-client.viewport-portrait #systemSideboardPage.dashboard-grid {
- grid-template-rows: repeat(10, minmax(0, 1fr)) !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .side-load,
-body.eink-client #systemSideboardPage.dashboard-grid .metric-card,
-body.eink-client #systemSideboardPage.dashboard-grid .side-feed,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card {
- min-height: 0 !important;
- height: auto !important;
- padding: 10px !important;
- overflow: hidden !important;
- border-width: 2px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .side-load {
- align-content: center !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .metric-card {
- gap: 4px 7px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .metric-value {
- font-size: clamp(28px, 3.8vw, 44px) !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .metric-sub {
- font-size: clamp(12px, 1.3vw, 16px) !important;
- line-height: 1.2 !important;
- white-space: nowrap !important;
- overflow: hidden !important;
- text-overflow: ellipsis !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .mini-bar {
- height: 10px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .side-feed {
- display: grid !important;
- grid-template-rows: auto minmax(0, 1fr) !important;
- align-content: stretch !important;
- gap: 6px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .feed-list {
- gap: 3px !important;
- overflow: hidden !important;
- font-size: clamp(13px, 1.4vw, 17px) !important;
- line-height: 1.25 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .feed-list li {
- padding: 3px 0 !important;
- white-space: nowrap !important;
- overflow: hidden !important;
- text-overflow: ellipsis !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-cards-grid {
- display: contents !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-header,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-footer {
- flex: 0 0 auto;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list {
- min-height: 0;
- overflow: hidden !important;
-}
-
-body.eink-client .dashboard-edit-bar,
-body.eink-client .dashboard-config-panel {
- border: 3px solid #000;
- background: #fffef8;
- color: #000;
- box-shadow: none;
-}
-
-body.eink-client .dashboard-edit-bar {
- grid-template-columns: 1fr;
- padding: 8px;
-}
-
-body.eink-client .dashboard-edit-copy span,
-body.eink-client .dashboard-library-empty { color: #000; }
-
-body.eink-client .dashboard-edit-actions button,
-body.eink-client .dashboard-card-library button,
-body.eink-client .dashboard-selection-controls button,
-body.eink-client .dashboard-card-editor button {
- border: 2px solid #000;
- background: #fffef8;
- color: #000;
- font-size: 14px;
-}
-
-body.eink-client .dashboard-card-editor {
- border: 2px solid #000;
- background: #fffef8;
- box-shadow: none;
-}
-
-body.eink-client .dashboard-config-panel {
- inset: 70px 10px 10px;
- overflow: auto;
-}
-
-body.dashboard-edit-mode.eink-client.dashboard-viewer,
-body.dashboard-edit-mode.eink-client.viewer-immersive {
- overflow: hidden !important;
-}
-
-/* E-ink sideboard is always single-column; no orientation-dependent board. */
-
-/* Custom cards follow the same readable, single-column rules on small and
- * monochrome clients as the built-in sideboard pages. */
-body.phone-client.viewport-portrait .sideboard-page-tabs {
- grid-column: 1;
- overflow-x: auto;
- padding-bottom: 2px;
-}
-
-body.phone-client.viewport-portrait .custom-sideboard-page {
- grid-column: 1;
- grid-template-rows: auto auto auto;
- gap: 10px;
-}
-
-body.phone-client.viewport-portrait .custom-cards-top,
-body.phone-client.viewport-portrait .custom-manager-header {
- align-items: stretch;
- flex-direction: column;
-}
-
-body.phone-client.viewport-portrait .custom-cards-toolbar,
-body.phone-client.viewport-portrait .custom-source-actions,
-body.phone-client.viewport-portrait .custom-source-form-actions {
- justify-content: stretch;
-}
-
-body.phone-client.viewport-portrait .custom-cards-toolbar button,
-body.phone-client.viewport-portrait .custom-manager-header button,
-body.phone-client.viewport-portrait .custom-source-actions button,
-body.phone-client.viewport-portrait .custom-source-form-actions button {
- flex: 1 1 auto;
- min-height: 40px;
-}
-
-body.phone-client.viewport-portrait .custom-cards-grid {
- grid-template-columns: 1fr;
- overflow: visible;
-}
-
-body.phone-client.viewport-portrait .custom-source-row {
- align-items: stretch;
- flex-direction: column;
-}
-
-body.phone-client.viewport-portrait .custom-source-actions {
- justify-content: stretch;
-}
-
-body.phone-client.viewport-portrait .custom-source-form {
- grid-template-columns: 1fr;
-}
-
-body.phone-client.viewport-portrait .custom-source-form > strong,
-body.phone-client.viewport-portrait .custom-credential-panel > strong,
-body.phone-client.viewport-portrait .custom-credential-panel > span,
-body.phone-client.viewport-portrait .custom-credential-panel > textarea,
-body.phone-client.viewport-portrait .custom-source-form-actions {
- grid-column: 1;
-}
-
-body.eink-client .sideboard-page-tabs {
- gap: 8px;
-}
-
-body.eink-client .sideboard-page-tabs button,
-body.eink-client .custom-cards-toolbar button,
-body.eink-client .custom-manager-header button,
-body.eink-client .custom-source-actions button,
-body.eink-client .custom-source-form-actions button {
- min-height: 44px;
- padding: 6px 12px;
- border: 3px solid #000 !important;
- border-radius: 4px;
- background: #fffef8 !important;
- color: #000 !important;
- font-size: 16px !important;
- font-weight: 800;
- white-space: nowrap;
-}
-
-body.eink-client .sideboard-page-tabs button.active {
- background: #000 !important;
- color: #fffef8 !important;
-}
-
-body.eink-client .custom-sideboard-page,
-body.eink-client .custom-cards-grid,
-body.eink-client .custom-sources-manager {
- gap: 16px !important;
-}
-
-body.eink-client .custom-cards-title strong,
-body.eink-client .custom-manager-header strong,
-body.eink-client .custom-card-title,
-body.eink-client .custom-status-value,
-body.eink-client .custom-metric-value,
-body.eink-client .custom-card-empty,
-body.eink-client .custom-status-detail,
-body.eink-client .custom-feed-text,
-body.eink-client .custom-key-value-list dd,
-body.eink-client .custom-card-footer,
-body.eink-client .custom-card-source,
-body.eink-client .custom-feed-meta,
-body.eink-client .custom-cards-title span,
-body.eink-client .custom-manager-header span,
-body.eink-client .custom-source-meta,
-body.eink-client .custom-source-health,
-body.eink-client .custom-source-form label,
-body.eink-client .custom-credential-panel > span,
-body.eink-client .custom-key-value-list dt {
- color: #000 !important;
- font-size: 18px !important;
- line-height: 1.45 !important;
- text-shadow: none !important;
-}
-
-body.eink-client .custom-card,
-body.eink-client .custom-empty-state,
-body.eink-client .custom-sources-manager,
-body.eink-client .custom-credential-panel,
-body.eink-client .custom-source-row,
-body.eink-client .custom-feed-item {
- background: #fffef8 !important;
- border: 3px solid #000 !important;
- border-radius: 4px !important;
- box-shadow: none !important;
-}
-
-body.eink-client .custom-card {
- min-height: 190px;
- padding: 16px;
-}
-
-body.eink-client .custom-card-title,
-body.eink-client .custom-status-value,
-body.eink-client .custom-metric-value,
-body.eink-client .custom-cards-title strong,
-body.eink-client .custom-manager-header strong {
- font-size: 28px !important;
- font-weight: 800;
-}
-
-body.eink-client .custom-card-freshness {
- border: 2px solid #000 !important;
- color: #000 !important;
- font-size: 16px !important;
-}
-
-body.eink-client .custom-feed-list,
-body.eink-client .custom-key-value-list {
- gap: 10px !important;
- font-size: 18px !important;
-}
-
-body.eink-client .custom-feed-item {
- border-left: 10px solid #000 !important;
- padding: 10px !important;
-}
-
-body.eink-client .custom-progress {
- height: 14px !important;
- border: 2px solid #000 !important;
- background: #e8e3d3 !important;
-}
-
-body.eink-client .custom-progress span {
- background: #000 !important;
-}
-
-body.eink-client .custom-source-form input,
-body.eink-client .custom-source-form select,
-body.eink-client .custom-credential-panel textarea {
- min-height: 48px;
- border: 3px solid #000 !important;
- border-radius: 4px;
- background: #fffef8 !important;
- color: #000 !important;
- font-size: 18px !important;
-}
-
-/* Edit mode intentionally allows vertical movement; read mode remains the
- strict one-screen canvas above. Keep this last so legacy fullscreen rules
- cannot lock the editor to a clipped 100dvh box. */
-body.dashboard-edit-mode.eink-client.dashboard-viewer,
-body.dashboard-edit-mode.eink-client.viewer-immersive {
- height: auto !important;
- min-height: 100dvh !important;
- overflow-x: hidden !important;
- overflow-y: auto !important;
- position: absolute !important;
-}
-
-body.dashboard-edit-mode.eink-client main,
-body.dashboard-edit-mode.eink-client .sideboard-view.active {
- height: auto !important;
- min-height: 100dvh !important;
- overflow: visible !important;
-}
-
-body.dashboard-edit-mode.eink-client .sideboard-shell.dashboard-editing {
- height: auto !important;
- min-height: 100dvh !important;
- overflow: visible !important;
-}
-
-body.dashboard-edit-mode.eink-client .sideboard-shell.dashboard-editing #systemSideboardPage.dashboard-grid {
- height: calc(100dvh - 92px) !important;
- min-height: 820px !important;
-}
-
-/* BOOX fallback: Chrome can preserve an old install-time landscape lock even
- when Android auto-rotate is enabled. The motion sensor marks the physical
- placement and this rotates a portrait-sized dashboard canvas into it. */
-html.eink-sensor-portrait {
- width: 100%;
- height: 100%;
- overflow: hidden;
-}
-
-body.eink-client.eink-sensor-portrait {
- position: fixed !important;
- width: 100dvh !important;
- height: 100dvw !important;
- max-width: none !important;
- max-height: none !important;
- margin: 0 !important;
- overflow: hidden !important;
- transform-origin: top left !important;
-}
-
-body.eink-client.eink-sensor-portrait-primary {
- top: 100% !important;
- left: 0 !important;
- transform: rotate(-90deg) !important;
-}
-
-body.eink-client.eink-sensor-portrait-secondary {
- top: 0 !important;
- left: 100% !important;
- transform: rotate(90deg) !important;
-}
-
-/* One-screen e-ink dashboard: reserve explicit rows so the load heading and
- summary never get pushed through the card boundary. */
-body.eink-client #systemSideboardPage.dashboard-grid .side-load {
- grid-template-rows: auto minmax(0, 1fr) auto !important;
- align-content: stretch !important;
- gap: 4px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-big {
- align-self: center !important;
- font-size: clamp(42px, 7vw, 62px) !important;
- line-height: .9 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-summary {
- gap: 1px !important;
- min-height: 0 !important;
- font-size: clamp(12px, 1.2vw, 15px) !important;
- line-height: 1.15 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-summary span {
- overflow: hidden !important;
- font-size: inherit !important;
- line-height: inherit !important;
- text-overflow: ellipsis !important;
- white-space: nowrap !important;
-}
-
-/* Notification feed owns its scroll area. Compact chrome leaves enough room
- for readable notification rows without growing the one-screen canvas. */
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed {
- gap: 5px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-title {
- font-size: 23px !important;
- line-height: 1.05 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-source,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-footer,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-freshness {
- font-size: 13px !important;
- line-height: 1.15 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-freshness {
- padding: 2px 4px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list {
- min-height: 0 !important;
- gap: 5px !important;
- overflow-x: hidden !important;
- overflow-y: auto !important;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
- scrollbar-width: auto;
- touch-action: pan-y;
- -webkit-overflow-scrolling: touch;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-item {
- padding: 6px 7px !important;
- border-width: 2px 2px 2px 7px !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-meta {
- font-size: 12px !important;
- line-height: 1.15 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-text {
- font-size: 15px !important;
- line-height: 1.25 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list::-webkit-scrollbar {
- width: 12px;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list::-webkit-scrollbar-track {
- border-left: 2px solid #000;
- background: #fffef8;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-list::-webkit-scrollbar-thumb {
- border: 2px solid #fffef8;
- background: #000;
-}
-
-/* Work Pulse's default copy is the e-ink readability baseline. Metadata and
- secondary values may be quieter, but never physically smaller. */
-body.eink-client {
- --eink-dashboard-min-font: 17px;
-}
-
-body.eink-client .side-title span,
-body.eink-client #systemSideboardPage.dashboard-grid .side-kicker,
-body.eink-client #systemSideboardPage.dashboard-grid .feed-heading,
-body.eink-client #systemSideboardPage.dashboard-grid .metric-sub,
-body.eink-client #systemSideboardPage.dashboard-grid .side-summary,
-body.eink-client #systemSideboardPage.dashboard-grid .feed-list,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-source,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-footer,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-freshness,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-meta,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-feed-text,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-empty,
-body.eink-client .dashboard-edit-copy span,
-body.eink-client .dashboard-library-empty,
-body.eink-client .dashboard-edit-actions button,
-body.eink-client .dashboard-card-library button,
-body.eink-client .dashboard-selection-controls button,
-body.eink-client .dashboard-card-editor button {
- font-size: var(--eink-dashboard-min-font) !important;
- line-height: 1.2 !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .side-load .side-summary {
- font-size: var(--eink-dashboard-min-font) !important;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-source,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-footer,
-body.eink-client #systemSideboardPage.dashboard-grid .custom-card-message-feed .custom-card-freshness {
- font-size: var(--eink-dashboard-min-font) !important;
-}
-
-body.eink-client .dashboard-card-editor {
- display: grid !important;
- grid-template-columns: repeat(3, auto);
- max-width: calc(100% - 12px);
-}
-
-body.eink-client .dashboard-card-editor button {
- min-width: 60px;
- min-height: 40px;
- padding: 4px 7px;
- white-space: nowrap;
-}
-
-/* Dashboard-local navigation appears only where the global header is hidden
- (immersive fullscreen / deck window); otherwise the top tabs are the single
- navigation and the in-panel switch is hidden. */
-body:not(.dashboard-viewer):not(.viewer-immersive):not(.deck-window) .dashboard-mode-switch {
- display: none;
-}
-
-.dashboard-view-controls,
-.dashboard-mode-switch {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
- gap: 6px;
-}
-
-.dashboard-mode-switch {
- padding: 3px;
- border: 1px solid var(--side-line, rgba(255,255,255,.22));
- border-radius: 5px;
- background: rgba(255,255,255,.06);
-}
-
-.dashboard-mode-switch button {
- min-height: 38px;
- padding: 5px 10px;
- border: 1px solid transparent;
- border-radius: 3px;
- background: transparent;
- color: inherit;
- font-size: 17px;
- font-weight: 800;
- white-space: nowrap;
-}
-
-.dashboard-mode-switch button.active {
- border-color: var(--side-accent, currentColor);
- background: var(--side-accent, #fff);
- color: #071015;
-}
-
-body.phone-client.dashboard-viewer .eink-connection-state,
-body.phone-client.viewer-immersive .eink-connection-state,
-body.phone-client.deck-window .eink-connection-state {
- display: inline-flex;
- flex: 0 0 auto;
- align-items: center;
- justify-content: center;
- min-height: 42px;
- padding: 4px 10px;
- border: 2px solid currentColor;
- border-radius: 4px;
- color: var(--side-accent, #fff);
- background: transparent;
- font-size: 17px;
- font-weight: 900;
- line-height: 1;
- white-space: nowrap;
-}
-
-body.phone-client .eink-connection-state.online {
- color: #071015;
- border-color: var(--side-accent, #fff);
- background: var(--side-accent, #fff);
-}
-
-body.eink-client .eink-connection-state {
- border: 3px solid #000;
- color: #000;
- background: #fffef8;
-}
-
-body.eink-client .eink-connection-state.online {
- border-color: #000;
- color: #fffef8;
- background: #000;
-}
-
-body.eink-client .dashboard-mode-switch {
- border: 2px solid #000;
- border-radius: 4px;
- background: #fffef8;
-}
-
-body.eink-client .dashboard-mode-switch button {
- min-height: 42px;
- border: 2px solid transparent;
- background: #fffef8;
- color: #000;
-}
-
-body.eink-client .dashboard-mode-switch button.active {
- border-color: #000;
- background: #000;
- color: #fffef8;
-}
-
-/* The previous readability and card-editing work also applies to the regular
- phone dashboard, not only to BOOX mode. */
-body.phone-client:not(.eink-client) {
- --eink-dashboard-min-font: 17px;
-}
-
-body.phone-client:not(.eink-client) .side-title span,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .side-kicker,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .feed-heading,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .metric-sub,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .side-summary,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .feed-list,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-source,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-footer,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-freshness,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-feed-meta,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-feed-text,
-body.phone-client:not(.eink-client) #systemSideboardPage.dashboard-grid .custom-card-empty,
-body.phone-client:not(.eink-client) .dashboard-edit-copy span,
-body.phone-client:not(.eink-client) .dashboard-library-empty,
-body.phone-client:not(.eink-client) .dashboard-edit-actions button,
-body.phone-client:not(.eink-client) .dashboard-card-library button,
-body.phone-client:not(.eink-client) .dashboard-selection-controls button,
-body.phone-client:not(.eink-client) .dashboard-card-editor button {
- font-size: var(--eink-dashboard-min-font) !important;
- line-height: 1.2 !important;
-}
-
-body.phone-client.viewport-portrait:not(.eink-client) .sideboard-top,
-body.phone-client.viewport-portrait:not(.eink-client) .quota-top {
- grid-template-columns: minmax(0, 1fr) !important;
-}
-
-body.phone-client.viewport-portrait:not(.eink-client) .dashboard-view-controls {
- width: 100%;
- flex-wrap: wrap;
- justify-content: flex-start;
-}
-
-@media (max-width: 960px) {
- body.ios-client.phone-client:not(.eink-client).dashboard-viewer.mode-sideboard .sideboard-shell {
- padding-top: calc(8px + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
- padding-right: calc(10px + var(--safe-area-inset-right, env(safe-area-inset-right, 0px)));
- padding-bottom: calc(8px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
- padding-left: calc(10px + var(--safe-area-inset-left, env(safe-area-inset-left, 0px)));
- }
-}
-
-/* The large load number is reserved for an actual bottleneck. Idle systems
- get a quiet animated companion; multiple warnings rotate in the same spot. */
-.side-load-normal,
-.side-load-alert {
- display: grid;
- justify-items: center;
- align-content: center;
- gap: 5px;
- min-height: 0;
- text-align: center;
-}
-
-.side-load-normal[hidden],
-.side-load-alert[hidden] { display: none !important; }
-
-.side-load-normal > strong,
-.side-load-alert > strong {
- color: var(--side-accent);
- font-size: clamp(19px, 2.3vw, 30px);
- line-height: 1.05;
-}
-
-.side-load-normal > span,
-.side-load-alert > span {
- color: var(--side-muted);
- font-size: clamp(12px, 1.4vw, 17px);
-}
-
-.side-load-alert > strong,
-.side-load-alert > span { color: var(--side-warn); }
-
-.load-pet {
- position: relative;
- width: clamp(72px, 8vw, 112px);
- height: clamp(54px, 6vw, 82px);
- border: 4px solid var(--side-accent);
- border-radius: 42% 42% 48% 48%;
- background: rgba(255,255,255,.05);
- animation: load-pet-breathe 2.8s ease-in-out infinite;
-}
-
-.load-pet::before,
-.load-pet::after {
- content: "";
- position: absolute;
- top: -16px;
- width: 22px;
- height: 22px;
- border: 4px solid var(--side-accent);
- background: var(--side-panel);
- transform: rotate(45deg);
-}
-
-.load-pet::before { left: 5px; }
-.load-pet::after { right: 5px; }
-
-.load-pet span,
-.load-pet span::after {
- position: absolute;
- top: 22px;
- width: 9px;
- height: 13px;
- border-radius: 50%;
- background: var(--side-accent);
- content: "";
-}
-
-.load-pet span { left: 24%; }
-.load-pet span::after { left: 250%; top: 0; }
-
-@keyframes load-pet-breathe {
- 0%, 100% { transform: translateY(1px) scale(1); }
- 50% { transform: translateY(-4px) scale(1.03); }
-}
-
-body.eink-client .side-load-normal > strong,
-body.eink-client .side-load-alert > strong,
-body.eink-client .side-load-normal > span,
-body.eink-client .side-load-alert > span {
- color: #000;
- font-size: var(--eink-dashboard-min-font) !important;
-}
-
-body.eink-client .load-pet {
- border-color: #000;
- background: #fffef8;
- animation: none;
-}
-
-body.eink-client .load-pet::before,
-body.eink-client .load-pet::after {
- border-color: #000;
- background: #fffef8;
-}
-
-body.eink-client .load-pet span,
-body.eink-client .load-pet span::after { background: #000; }
-
-/* Work Pulse and Windows notifications share one chronological activity card. */
-.activity-feed-card {
- display: grid;
- grid-template-rows: auto minmax(0, 1fr);
- gap: 7px;
- overflow: hidden;
-}
-
-.activity-feed-head,
-.quota-mini-head,
-.quota-mini-value-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 8px;
- min-width: 0;
-}
-
-.activity-feed-filters {
- display: flex;
- gap: 4px;
- flex: 0 0 auto;
-}
-
-.activity-feed-filters button {
- min-height: 30px;
- padding: 3px 10px;
- border: 1px solid var(--side-line);
- border-radius: 2px;
- background: transparent;
- color: var(--side-muted);
- font: inherit;
- font-weight: 700;
-}
-
-.activity-feed-filters button.active {
- border-color: var(--side-accent);
- background: var(--side-accent);
- color: #071015;
-}
-
-.activity-feed-list {
- display: grid;
- align-content: start;
- gap: 5px;
- min-width: 0;
- min-height: 0;
- margin: 0;
- padding: 0 4px 0 0;
- overflow-x: hidden;
- overflow-y: auto;
- overscroll-behavior: contain;
- scrollbar-gutter: stable;
- touch-action: pan-y;
- list-style: none;
- scrollbar-width: thin;
-}
-
-.activity-feed-item {
- min-width: 0;
- padding: 6px 8px;
- border: 1px solid var(--side-line);
- background: rgba(7, 16, 21, .46);
-}
-
-.activity-feed-meta {
- display: flex;
- align-items: center;
- gap: 7px;
- min-width: 0;
- color: var(--side-muted);
- font-size: 13px;
-}
-
-.activity-feed-meta time { margin-left: auto; }
-
-.activity-source {
- flex: 0 0 auto;
- color: var(--side-accent);
- font-weight: 800;
-}
-
-.activity-from {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.activity-feed-text {
- margin-top: 2px;
- overflow-wrap: anywhere;
- color: var(--side-text);
- line-height: 1.28;
-}
-
-.activity-feed-empty {
- align-self: center;
- padding: 12px 0;
- color: var(--side-muted);
- text-align: center;
-}
-
-.quota-mini-card {
- display: grid;
- grid-template-rows: auto auto auto auto 8px;
- align-content: center;
- gap: 5px;
- overflow: hidden;
-}
-
-.quota-mini-head strong { color: var(--side-accent); }
-.quota-mini-head span,
-.quota-mini-value-row span { color: var(--side-muted); }
-
-.quota-mini-card select {
- min-width: 0;
- width: 100%;
- min-height: 30px;
- padding: 3px 7px;
- border: 1px solid var(--side-line);
- border-radius: 2px;
- background: rgba(7, 16, 21, .72);
- color: var(--side-text);
- font: inherit;
- font-weight: 700;
- text-overflow: ellipsis;
-}
-
-.quota-mini-value-row strong {
- color: var(--side-accent);
- font-size: clamp(25px, 3vw, 40px);
- line-height: .95;
-}
-
-.activity-feed-filter-select {
- display: none;
-}
-
-.quota-mini-credit {
- overflow: hidden;
- color: var(--side-muted);
- font-size: 17px;
- font-weight: 700;
- line-height: 1.2;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.quota-mini-bar {
- height: 8px;
- border: 1px solid var(--side-line);
- overflow: hidden;
-}
-
-.quota-mini-bar span {
- display: block;
- width: 0;
- height: 100%;
- background: var(--side-accent);
- transition: width .18s ease;
-}
-
-body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-card,
-body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-card {
- padding: 9px !important;
- border: 2px solid #000 !important;
- background: #fffef8 !important;
- color: #000 !important;
-}
-
-body.eink-client .activity-feed-filters button {
- min-height: 38px;
- padding: 3px 11px;
- border: 2px solid #000;
- background: #fffef8;
- color: #000;
- font-size: 17px;
-}
-
-body.eink-client .activity-feed-filters button.active {
- background: #000;
- color: #fffef8;
-}
-
-body.eink-client .activity-feed-filters {
- display: none !important;
-}
-
-body.eink-client .activity-feed-filter-select {
- display: block;
- flex: 0 1 180px;
- width: min(180px, 48%);
- min-width: 120px;
- min-height: 42px;
- padding: 4px 34px 4px 10px;
- border: 2px solid #000;
- border-radius: 2px;
- background: #fffef8;
- color: #000;
- font: inherit;
- font-size: 17px;
- font-weight: 800;
-}
-
-body.eink-client .activity-feed-list { scrollbar-width: auto; }
-
-body.eink-client .activity-feed-item {
- padding: 6px 8px;
- border: 2px solid #000;
- background: #fffef8;
- color: #000;
-}
-
-body.eink-client .activity-feed-meta,
-body.eink-client .activity-feed-text,
-body.eink-client .activity-feed-empty,
-body.eink-client .quota-mini-head,
-body.eink-client .quota-mini-head span,
-body.eink-client .quota-mini-value-row span,
-body.eink-client .quota-mini-card select {
- color: #000;
- font-size: var(--eink-dashboard-min-font) !important;
- line-height: 1.2;
-}
-
-body.eink-client .activity-source,
-body.eink-client .quota-mini-head strong,
-body.eink-client .quota-mini-value-row strong {
- color: #000;
- font-weight: 900;
-}
-
-body.eink-client .activity-source {
- padding: 1px 5px;
- border: 2px solid #000;
-}
-
-body.eink-client .activity-feed-list::-webkit-scrollbar { width: 12px; }
-body.eink-client .activity-feed-list::-webkit-scrollbar-track {
- border-left: 2px solid #000;
- background: #fffef8;
-}
-body.eink-client .activity-feed-list::-webkit-scrollbar-thumb {
- border: 2px solid #fffef8;
- background: #000;
-}
-
-body.eink-client .quota-mini-card select {
- min-height: 36px;
- border: 2px solid #000;
- background: #fffef8;
-}
-
-body.eink-client .quota-mini-value-row strong {
- font-size: clamp(30px, 4vw, 46px) !important;
-}
-
-body.eink-client .quota-credit-row,
-body.eink-client .quota-mini-credit {
- color: #000 !important;
- font-size: var(--eink-dashboard-min-font) !important;
- line-height: 1.2 !important;
-}
-
-body.eink-client .quota-mini-bar {
- height: 10px;
- border: 2px solid #000;
-}
-
-body.eink-client .quota-mini-bar span {
- background: #000;
- transition: none;
-}
-
-body.phone-client:not(.eink-client) .activity-feed-filters button,
-body.phone-client:not(.eink-client) .activity-feed-meta,
-body.phone-client:not(.eink-client) .activity-feed-text,
-body.phone-client:not(.eink-client) .activity-feed-empty,
-body.phone-client:not(.eink-client) .quota-mini-head,
-body.phone-client:not(.eink-client) .quota-mini-value-row span,
-body.phone-client:not(.eink-client) .quota-mini-card select {
- font-size: 17px;
-}
-
-body.phone-client:not(.eink-client) .quota-credit-row,
-body.phone-client:not(.eink-client) .quota-mini-credit {
- font-size: var(--eink-dashboard-min-font) !important;
- line-height: 1.2 !important;
-}
-
-.energy-wave-backdrop {
- position: absolute;
- inset: 0;
- z-index: 0;
- overflow: hidden;
- pointer-events: none;
- background: #000;
- isolation: isolate;
- border-radius: inherit;
- opacity: 0;
- transition: opacity 420ms ease;
-}
-
-.energy-wave-backdrop.is-active {
- opacity: 1;
-}
-
-.energy-wave-canvas,
-.energy-wave-strips,
-.energy-wave-vignette {
- position: absolute;
- inset: 0;
-}
-
-.energy-wave-canvas {
- z-index: 1;
- width: 100%;
- height: 100%;
-}
-
-.energy-wave-strips {
- z-index: 2;
- display: grid;
- grid-template-columns: repeat(24, minmax(0, 1fr));
- mix-blend-mode: screen;
-}
-
-.energy-wave-strip {
- min-width: 0;
- border-left: 1px solid rgba(191, 235, 255, 0.13);
- background:
- linear-gradient(90deg,
- rgba(79, 207, 255, 0.08) 0%,
- rgba(255, 255, 255, 0.024) 28%,
- rgba(230, 109, 255, 0.065) 67%,
- rgba(255, 198, 94, 0.07) 100%);
- box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.035), inset -1px 0 0 rgba(22, 93, 160, 0.06);
-}
-
-.energy-wave-strip:first-child {
- border-left: 0;
-}
-
-.energy-wave-strip:nth-child(3n) {
- background:
- linear-gradient(90deg,
- rgba(87, 229, 255, 0.11),
- rgba(255, 255, 255, 0.018) 43%,
- rgba(255, 87, 198, 0.09));
-}
-
-.energy-wave-strip:nth-child(4n) {
- background:
- linear-gradient(90deg,
- rgba(255, 207, 112, 0.09),
- rgba(255, 255, 255, 0.012) 46%,
- rgba(126, 118, 255, 0.08));
-}
-
-.energy-wave-vignette {
- z-index: 3;
- background:
- radial-gradient(ellipse 82% 70% at 50% 54%, transparent 25%, rgba(0, 0, 0, 0.56) 100%),
- linear-gradient(180deg, rgba(0, 0, 0, 0.44), transparent 24%, transparent 72%, rgba(0, 0, 0, 0.56));
-}
-
-body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell {
- --side-bg: #000;
- --side-panel: rgba(7, 13, 21, 0.57);
- background: #000;
- border-color: rgba(175, 234, 255, 0.22);
- box-shadow: 0 28px 72px rgba(0, 0, 0, 0.56), inset 0 1px 0 rgba(255, 255, 255, 0.045);
- isolation: isolate;
-}
-
-body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell::before {
- z-index: 1;
- opacity: 0.11;
- mix-blend-mode: screen;
-}
-
-body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell::after {
- z-index: 2;
- pointer-events: none;
- background:
- linear-gradient(90deg, rgba(0, 1, 3, 0.52), rgba(0, 2, 5, 0.12) 48%, rgba(0, 1, 3, 0.52)),
- radial-gradient(circle at 50% 47%, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.4) 78%);
-}
-
-body.mode-sideboard.energy-wave-active:not(.eink-client) .sideboard-shell > :not(.energy-wave-backdrop),
-body.mode-sideboard.energy-wave-active:not(.eink-client) #systemSideboardPage.dashboard-grid [data-dashboard-key] {
- z-index: 3;
-}
-
-body.mode-sideboard.energy-wave-active:not(.eink-client) .side-panel,
-body.mode-sideboard.energy-wave-active:not(.eink-client) .metric-card,
-body.mode-sideboard.energy-wave-active:not(.eink-client) .custom-card {
- backdrop-filter: blur(10px) saturate(1.08);
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05), 0 16px 36px rgba(0, 0, 0, 0.28);
-}
-
-body.eink-client .energy-wave-backdrop {
- display: none !important;
-}
-
-body.phone-client:not(.eink-client).energy-wave-active .mobile-overview-section,
-body.phone-client:not(.eink-client).energy-wave-active .mobile-detail-block,
-body.phone-client:not(.eink-client).energy-wave-active .mobile-detail-host,
-body.phone-client:not(.eink-client).energy-wave-active .mobile-detail-metrics article {
- background: linear-gradient(145deg, rgba(14, 34, 40, .78), rgba(7, 20, 25, .72));
- backdrop-filter: blur(10px) saturate(1.08);
- -webkit-backdrop-filter: blur(10px) saturate(1.08);
-}
-
-body.phone-client:not(.eink-client).energy-wave-active .mobile-metric-card {
- background: rgba(8, 24, 29, .7);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .energy-wave-backdrop {
- transition: none;
- }
-}
-
-.quota-codex-switcher {
- grid-template-rows: auto auto auto;
-}
-
-.quota-codex-switch-row {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
- min-width: 0;
-}
-
-.quota-codex-select {
- flex: 1 1 190px;
- min-width: 0;
- min-height: 34px;
- padding: 6px 10px;
- border: 1px solid rgba(255,255,255,.2);
- border-radius: 8px;
- background: rgba(0,0,0,.22);
- color: inherit;
- font: inherit;
- font-size: 14px;
-}
-
-body.eink-client .quota-codex-select {
- min-height: 48px;
- border: 3px solid #000;
- border-radius: 4px;
- background: #fffef8;
- color: #000;
- font-size: 18px;
- font-weight: 800;
-}
-
-/* E-ink layout safety net ----------------------------------------------------
- * BOOX / e-reader browsers span a wide range of CSS viewport sizes. Keep
- * cards readable, but never let a long label or a dense action row widen the
- * page beyond the paper viewport. This block stays last on purpose. */
-body.eink-client {
- overflow-x: hidden;
-}
-
-body.eink-client *,
-body.eink-client *::before,
-body.eink-client *::after {
- box-sizing: border-box;
-}
-
-body.eink-client header,
-body.eink-client main,
-body.eink-client .app-view,
-body.eink-client .sideboard-view,
-body.eink-client .quota-view,
-body.eink-client .sideboard-shell,
-body.eink-client .quota-shell,
-body.eink-client .sideboard-top,
-body.eink-client .quota-top,
-body.eink-client .dashboard-edit-bar,
-body.eink-client .dashboard-config-panel,
-body.eink-client .custom-sideboard-page,
-body.eink-client .custom-cards-grid,
-body.eink-client .custom-sources-manager,
-body.eink-client .quota-account-card,
-body.eink-client .quota-account-head,
-body.eink-client .quota-codex-switch-row {
- min-width: 0;
- max-width: 100%;
-}
-
-body.eink-client img,
-body.eink-client video,
-body.eink-client canvas,
-body.eink-client svg,
-body.eink-client input,
-body.eink-client select,
-body.eink-client textarea,
-body.eink-client button {
- max-width: 100%;
-}
-
-body.eink-client .brand-copy,
-body.eink-client .side-title,
-body.eink-client .side-title span,
-body.eink-client .quota-title,
-body.eink-client .quota-title span,
-body.eink-client .quota-status,
-body.eink-client .quota-account-email,
-body.eink-client .quota-account-switcher,
-body.eink-client .quota-action-status,
-body.eink-client .quota-window small,
-body.eink-client .custom-source-meta,
-body.eink-client .custom-source-health,
-body.eink-client .custom-card-source,
-body.eink-client .custom-card-footer,
-body.eink-client .custom-feed-text,
-body.eink-client .custom-feed-meta {
- min-width: 0;
- overflow-wrap: anywhere;
-}
-
-body.eink-client .top-controls,
-body.eink-client .utility-controls,
-body.eink-client .sideboard-top,
-body.eink-client .dashboard-view-controls,
-body.eink-client .dashboard-mode-switch,
-body.eink-client .dashboard-edit-actions,
-body.eink-client .dashboard-card-library,
-body.eink-client .dashboard-selection-controls,
-body.eink-client .custom-cards-top,
-body.eink-client .custom-manager-header,
-body.eink-client .custom-source-row,
-body.eink-client .custom-source-actions,
-body.eink-client .custom-source-form-actions,
-body.eink-client .activity-feed-head,
-body.eink-client .quota-mini-head,
-body.eink-client .quota-mini-value-row {
- flex-wrap: wrap;
-}
-
-body.eink-client .sideboard-top {
- align-items: flex-start;
-}
-
-body.eink-client .side-title,
-body.eink-client .dashboard-view-controls {
- flex: 1 1 220px;
- max-width: 100%;
-}
-
-body.eink-client .dashboard-view-controls,
-body.eink-client .dashboard-mode-switch {
- min-width: 0;
- max-width: 100%;
-}
-
-body.eink-client .dashboard-mode-switch {
- flex: 1 1 180px;
-}
-
-body.eink-client .dashboard-mode-switch button {
- flex: 1 1 88px;
- min-width: 0;
- white-space: normal;
-}
-
-body.eink-client .eink-connection-state {
- max-width: 100%;
- white-space: normal;
- text-align: center;
-}
-
-/* The quota page used to keep a three-column desktop header and icon-sized
- * controls. E-ink action labels now wrap and remain tappable instead. */
-body.eink-client .quota-top {
- grid-template-columns: minmax(0, 1fr);
- align-items: start;
-}
-
-body.eink-client .quota-left,
-body.eink-client .quota-top .dashboard-view-controls,
-body.eink-client .quota-status {
- grid-column: 1;
- justify-self: start;
- width: 100%;
-}
-
-body.eink-client .quota-title {
- flex-wrap: wrap;
- white-space: normal;
-}
-
-body.eink-client .quota-tabs {
- width: 100%;
- max-width: 100%;
-}
-
-body.eink-client .quota-account-head {
- grid-template-columns: auto minmax(0, 1fr) auto;
- grid-template-rows: auto auto;
- align-items: start;
-}
-
-body.eink-client .quota-account-email {
- white-space: normal;
- overflow: visible;
- text-overflow: clip;
-}
-
-body.eink-client .quota-account-switcher {
- grid-column: 2 / -1;
- grid-row: 2;
- justify-self: start;
- white-space: normal;
-}
-
-body.eink-client .quota-pill {
- grid-column: 3;
- grid-row: 1;
- justify-self: end;
-}
-
-body.eink-client .quota-pair,
-body.eink-client .quota-footer {
- grid-template-columns: minmax(0, 1fr);
-}
-
-body.eink-client .quota-footer {
- justify-items: start;
-}
-
-body.eink-client .quota-toolbox {
- justify-self: start;
- display: flex;
- flex-wrap: wrap;
- width: 100%;
- max-width: 100%;
- gap: 8px;
-}
-
-body.eink-client .quota-toolbox button {
- flex: 1 1 152px;
- width: auto;
- min-width: 0;
- min-height: 48px;
- height: auto;
- padding: 6px 12px;
- white-space: normal;
- text-align: center;
- overflow-wrap: anywhere;
-}
-
-body.eink-client .quota-codex-switch-row {
- align-items: stretch;
-}
-
-body.eink-client .quota-codex-select {
- flex: 1 1 100%;
- width: 100%;
-}
-
-body.eink-client .quota-codex-switch-row .quota-toolbox {
- flex: 1 1 100%;
-}
-
-body.eink-client.dashboard-viewer main,
-body.eink-client.viewer-immersive main,
-body.eink-client.viewer-fullscreen main {
- height: 100dvh !important;
- min-height: 0 !important;
- overflow-x: hidden !important;
- overflow-y: auto !important;
-}
-
-body.eink-client.dashboard-viewer .quota-view.active,
-body.eink-client.viewer-immersive .quota-view.active,
-body.eink-client.viewer-fullscreen .quota-view.active,
-body.eink-client.mode-quota .quota-shell {
- height: auto !important;
- min-height: 100dvh !important;
- overflow: visible !important;
-}
-
-body.eink-client.mode-quota .quota-shell {
- grid-template-rows: auto auto auto !important;
- align-content: start;
-}
-
-body.eink-client.mode-quota .quota-grid,
-body.eink-client.mode-quota .quota-account-page,
-body.eink-client.mode-quota .quota-account-card {
- height: auto !important;
- min-height: 0 !important;
- align-content: start !important;
- overflow: visible !important;
-}
-
-body.eink-client.mode-quota .quota-account-card {
- display: block !important;
- grid-template-rows: auto auto auto auto auto !important;
- min-height: max-content !important;
- align-self: start !important;
-}
-
-body.eink-client.mode-quota .quota-account-card > * + * {
- margin-top: 16px;
-}
-
-body.eink-client.mode-quota .quota-pair {
- min-height: max-content !important;
-}
-
-body.eink-client.mode-quota .quota-eink-overview {
- display: flex !important;
- flex: 0 0 auto;
- flex-direction: column;
- align-self: start !important;
- gap: 14px;
- padding: 18px !important;
-}
-
-body.eink-client.mode-quota .quota-eink-overview > * {
- flex: 0 0 auto;
- min-width: 0;
- margin: 0 !important;
-}
-
-body.eink-client.mode-quota .quota-eink-overview-head {
- display: grid;
- gap: 10px;
-}
-
-body.eink-client.mode-quota .quota-eink-overview-head > .quota-account-switcher {
- grid-column: auto;
- grid-row: auto;
- justify-self: start;
-}
-
-body.eink-client.mode-quota .quota-eink-usage {
- display: block !important;
- height: auto !important;
- min-height: 0 !important;
- overflow: visible !important;
-}
-
-body.eink-client.mode-quota .quota-eink-usage .quota-provider-title {
- display: block;
- margin-bottom: 8px;
- font-size: 22px !important;
-}
-
-body.eink-client.mode-quota .quota-eink-usage .quota-window {
- gap: 6px 12px;
- padding: 14px 0;
- border-top: 2px solid #000;
-}
-
-body.eink-client.mode-quota .quota-eink-usage .quota-provider-title + .quota-window {
- border-top: 0;
-}
-
-body.eink-client.mode-quota .quota-eink-usage .quota-window b {
- font-size: clamp(36px, 6vw, 48px) !important;
-}
-
-body.eink-client.mode-quota .quota-eink-credit {
- flex-wrap: wrap;
- padding-top: 14px;
- border-top: 2px solid #000;
- font-size: 20px !important;
-}
-
-body.eink-client.mode-quota .quota-eink-overview-footer {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding-top: 14px;
- border-top: 2px solid #000;
-}
-
-body.eink-client.mode-quota .quota-eink-overview-footer .quota-toolbox {
- width: auto;
-}
-
-body.eink-client.mode-quota .quota-eink-overview-footer .quota-toolbox button {
- flex: 0 1 auto;
- width: auto;
- padding: 6px 14px;
-}
-
-body.eink-client.mode-quota .quota-eink-account-manager {
- margin: 0 !important;
- border: 2px solid #000;
- background: #f2f0e8;
-}
-
-body.eink-client.mode-quota .quota-eink-account-manager > summary {
- display: flex;
- align-items: center;
- justify-content: space-between;
- list-style: none;
- min-height: 52px;
- padding: 12px 14px;
- color: #000;
- font-size: 20px;
- font-weight: 800;
- cursor: pointer;
-}
-
-body.eink-client.mode-quota .quota-eink-account-manager > summary::-webkit-details-marker {
- display: none;
-}
-
-body.eink-client.mode-quota .quota-eink-account-manager > summary::after {
- content: "+";
- font-size: 26px;
- line-height: 1;
-}
-
-body.eink-client.mode-quota .quota-eink-account-manager[open] > summary {
- border-bottom: 2px solid #000;
-}
-
-body.eink-client.mode-quota .quota-eink-account-manager[open] > summary::after {
- content: "−";
-}
-
-body.eink-client.mode-quota .quota-eink-account-controls {
- min-width: 0;
- padding: 14px;
-}
-
-body.eink-client.mode-quota .quota-eink-account-controls .quota-codex-switch-row {
- display: grid;
- grid-template-columns: minmax(0, 1fr);
- gap: 12px;
-}
-
-body.eink-client.mode-quota .quota-eink-account-controls .quota-toolbox {
- display: grid !important;
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
- width: 100%;
- gap: 10px;
- padding: 0;
- border: 0;
- border-radius: 0;
- background: transparent;
-}
-
-body.eink-client.mode-quota .quota-eink-account-controls .quota-toolbox button {
- width: 100%;
- min-height: 52px;
- padding: 6px 12px;
-}
-
-body.eink-client .custom-cards-toolbar,
-body.eink-client .custom-source-actions,
-body.eink-client .custom-source-form-actions {
- flex: 1 1 100%;
- justify-content: flex-start;
-}
-
-body.eink-client .custom-cards-toolbar button,
-body.eink-client .custom-manager-header button,
-body.eink-client .custom-source-actions button,
-body.eink-client .custom-source-form-actions button {
- flex: 1 1 112px;
- min-width: 0;
- white-space: normal;
-}
-
-/* Language is a product-level setting, not a display-only control. Keep it
- * reachable after a phone or e-reader enters the trusted dashboard/quota UI. */
-body.phone-client.device-trusted:not(.mode-display) .utility-controls {
- display: flex !important;
- align-items: center;
- justify-content: flex-end;
- gap: 8px;
-}
-
-body.phone-client.device-trusted:not(.mode-display) .utility-controls > * {
- display: none !important;
-}
-
-body.phone-client.device-trusted:not(.mode-display) .utility-controls .locale-control {
- display: inline-flex !important;
- min-height: 44px;
-}
-
-body.phone-client.device-trusted:not(.mode-display) .locale-control select {
- min-height: 44px;
- max-width: 148px;
-}
-
-/* Persisted dashboard cards may be deliberately small. Containment lets their
- * own controls and figures adapt to the card width instead of pushing a row
- * outside the dashboard. */
-body.eink-client #systemSideboardPage.dashboard-grid [data-dashboard-key] {
- container-type: inline-size;
- container-name: eink-card;
-}
-
-@container eink-card (max-width: 280px) {
- body.eink-client #systemSideboardPage.dashboard-grid .metric-card,
- body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-card,
- body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-card {
- padding: 8px !important;
- }
-
- body.eink-client #systemSideboardPage.dashboard-grid .metric-value,
- body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-value-row strong,
- body.eink-client #systemSideboardPage.dashboard-grid .quota-window b {
- font-size: clamp(24px, 18cqw, 36px) !important;
- white-space: normal !important;
- overflow-wrap: anywhere;
- }
-
- body.eink-client #systemSideboardPage.dashboard-grid .side-big {
- font-size: clamp(32px, 28cqw, 56px) !important;
- white-space: normal !important;
- overflow-wrap: anywhere;
- }
-
- body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-head,
- body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-head,
- body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-value-row {
- align-items: stretch;
- }
-
- body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-head > .feed-heading,
- body.eink-client #systemSideboardPage.dashboard-grid .quota-mini-head > strong {
- flex: 1 1 100%;
- }
-
- body.eink-client #systemSideboardPage.dashboard-grid .activity-feed-filter-select {
- width: 100%;
- max-width: none;
- }
-}
-
-.phone-pair-rescue small {
- font-size: 12px;
-}
-
-body.phone-client:not(.eink-client) .quota-tab,
-body.phone-client:not(.eink-client) .quota-page-button {
- min-height: 36px;
-}
-
-body.phone-client:not(.eink-client) .quota-page-button {
- width: 32px;
- height: 36px;
-}
-
-body.phone-client:not(.eink-client) .quota-codex-select {
- min-height: 40px;
-}
-
-body.phone-client:not(.eink-client) .quota-mobile-account-manager {
- overflow: hidden;
- border: 1px solid rgba(255, 255, 255, .14);
- border-radius: 8px;
- background: rgba(255, 255, 255, .035);
-}
-
-body.phone-client:not(.eink-client) .quota-mobile-account-manager > summary {
- display: flex;
- align-items: center;
- justify-content: space-between;
- min-height: 44px;
- padding: 9px 11px;
- cursor: pointer;
- list-style: none;
- font-size: 14px;
- font-weight: 800;
-}
-
-body.phone-client:not(.eink-client) .quota-mobile-account-manager > summary::-webkit-details-marker {
- display: none;
-}
-
-body.phone-client:not(.eink-client) .quota-mobile-account-manager > summary::after {
- content: "+";
- font-size: 18px;
- line-height: 1;
-}
-
-body.phone-client:not(.eink-client) .quota-mobile-account-manager[open] > summary {
- border-bottom: 1px solid rgba(255, 255, 255, .12);
-}
-
-body.phone-client:not(.eink-client) .quota-mobile-account-manager[open] > summary::after {
- content: "−";
-}
-
-body.phone-client:not(.eink-client) .quota-mobile-account-manager .quota-codex-switch-row {
- padding: 10px;
-}
-
-body.phone-client:not(.eink-client) #quotaSummary,
-body.phone-client:not(.eink-client) #quotaUpdated,
-body.phone-client:not(.eink-client) .quota-help-block,
-body.phone-client:not(.eink-client) .quota-help-block li,
-body.phone-client:not(.eink-client) .quota-help-summary-hint,
-body.phone-client:not(.eink-client) .quota-action-status,
-body.phone-client:not(.eink-client) .quota-account-switcher,
-body.phone-client:not(.eink-client) .quota-pill,
-body.phone-client:not(.eink-client) .quota-active-badge,
-body.phone-client:not(.eink-client) .quota-window small,
-body.phone-client:not(.eink-client) .quota-time,
-body.phone-client:not(.eink-client) #quotaMiniReset {
- font-size: 12px;
-}
-
-body.phone-client:not(.eink-client) .quota-window span {
- font-size: 12px;
-}
-
-body.eink-client .phone-pair-rescue small,
-body.eink-client .phone-pair-rescue strong,
-body.eink-client .phone-pair-rescue > span,
-body.eink-client .phone-pair-rescue [data-pair-progress-detail],
-body.eink-client .phone-pair-rescue [data-pair-progress-label] {
- font-size: 16px !important;
-}
-
-body.eink-client .dashboard-view-switch button,
-body.eink-client .dashboard-mode-switch button,
-body.eink-client .activity-feed-filter-select,
-body.eink-client #quotaMiniSource,
-body.eink-client .quota-tab,
-body.eink-client .quota-page-button,
-body.eink-client .quota-codex-select {
- min-height: 44px !important;
-}
-
-body.eink-client .quota-page-button {
- width: 44px !important;
- height: 44px !important;
-}
-
-body.eink-client .quota-help-summary-hint,
-body.eink-client .quota-action-status,
-body.eink-client .quota-action-status span,
-body.eink-client .quota-account-switcher,
-body.eink-client .quota-account-switcher > span,
-body.eink-client .quota-pill {
- font-size: 16px !important;
-}
-
-body.mode-quota .quota-help {
- display: none;
-}
-
-/* Phone quota must be governed by the usable viewport, not the desktop card
- * minimum. This matters most on iPhone landscape where Safari leaves little
- * vertical room after the safe areas and browser chrome. */
-@media (max-width: 960px) {
- body.phone-client:not(.eink-client).mode-quota main {
- min-height: 0;
- overflow-x: hidden;
- overflow-y: auto;
- align-content: start;
- -webkit-overflow-scrolling: touch;
- }
-
- body.phone-client:not(.eink-client).mode-quota .quota-view {
- width: 100%;
- max-width: none;
- height: auto;
- min-height: 0;
- justify-self: stretch;
- align-self: start;
- }
-
- body.phone-client:not(.eink-client).mode-quota .quota-shell {
- min-height: 0;
- height: auto;
- grid-template-rows: auto auto;
- align-content: start;
- overflow: visible;
- }
-
- body.phone-client:not(.eink-client).mode-quota .quota-grid,
- body.phone-client:not(.eink-client).mode-quota .quota-account-page,
- body.phone-client:not(.eink-client).mode-quota .quota-account-card {
- height: auto;
- min-height: 0;
- overflow: visible;
- }
-
- body.phone-client:not(.eink-client).mode-quota .quota-grid {
- grid-auto-rows: max-content;
- }
-
- body.phone-client:not(.eink-client).mode-quota .quota-account-card {
- display: block;
- min-height: max-content;
- align-self: start;
- }
-
- body.phone-client:not(.eink-client).mode-quota .quota-account-card > * + * {
- margin-top: 10px;
- }
-}
-
-@media (max-width: 960px) and (orientation: landscape) {
- body.phone-client:not(.eink-client).mode-quota header {
- grid-template-columns: minmax(0, 1fr);
- gap: 6px;
- padding-top: calc(6px + var(--safe-area-inset-top, env(safe-area-inset-top)));
- padding-bottom: 6px;
- }
-
- body.phone-client:not(.eink-client).mode-quota .brand {
- display: none;
- }
-
- body.phone-client:not(.eink-client).mode-quota .top-controls {
- grid-template-columns: minmax(0, 1fr) auto;
- gap: 6px;
- width: 100%;
- }
-
- body.phone-client:not(.eink-client).mode-quota .utility-controls {
- gap: 5px;
- }
-
- body.phone-client:not(.eink-client).mode-quota .utility-controls button {
- min-height: 36px;
- padding-inline: 8px;
- }
-
- body.phone-client:not(.eink-client).mode-quota main {
- padding-top: 8px;
- }
-}
+/* VibeDeck styles, split into ordered partials. Load order IS the cascade
+ contract - do not reorder. Concatenating css/*.css in this exact order
+ reproduces the original single index.css byte-for-byte. */
+@import url("/css/00-base-tokens.css");
+@import url("/css/10-core.css");
+@import url("/css/20-pairing.css");
+@import url("/css/30-phone-dashboard.css");
+@import url("/css/40-eink-sideboard.css");
+@import url("/css/50-activity.css");
diff --git a/src/PhoneMonitor.Host/wwwroot/index.js b/src/PhoneMonitor.Host/wwwroot/index.js
index 77a536c..8d6fca3 100644
--- a/src/PhoneMonitor.Host/wwwroot/index.js
+++ b/src/PhoneMonitor.Host/wwwroot/index.js
@@ -30,6 +30,42 @@ import {
} from "./modules/quota-formatters.js?v=51";
import { getIntlLocale, initLocale, onLocaleChange, t, tApi, tLegacy, translateText } from "./modules/i18n.js?v=4";
import { createEnergyWave } from "./modules/energy-wave.js?v=7";
+import {
+ DEVICE_TOKEN_KEY,
+ LEGACY_DEVICE_TOKEN_KEY,
+ DEVICE_ID_KEY,
+ LEGACY_DEVICE_ID_KEY,
+ DEVICE_COOKIE,
+ LEGACY_DEVICE_COOKIE,
+ DEVICE_TOKEN_HISTORY_KEY,
+ DEVICE_TOKEN_HISTORY_LIMIT,
+ readCookie,
+ writeCookie,
+ writeDeviceCookies,
+ loadStoredDeviceCredentials,
+ loadDeviceTokenHistory,
+ saveDeviceTokenHistory,
+} from "./modules/device-credentials.js?v=1";
+import {
+ CLIENT_INSTANCE_KEY,
+ LEGACY_CLIENT_INSTANCE_KEY,
+ CLIENT_INSTANCE_COOKIE,
+ LEGACY_CLIENT_INSTANCE_COOKIE,
+ getOrCreateClientInstanceId,
+} from "./modules/client-instance.js?v=1";
+import {
+ isLoopbackHost,
+ isIosUA,
+ isIphoneUA,
+ isMobileUA,
+} from "./modules/env-detect.js?v=1";
+import {
+ EINK_PREF_KEY,
+ readEinkQuery,
+ readEinkCookie,
+ looksLikeBooxScreen,
+ detectEinkHardware,
+} from "./modules/eink-detect.js?v=1";
const screen = document.getElementById("screen");
const statusText = document.getElementById("status");
@@ -280,77 +316,10 @@ import { createEnergyWave } from "./modules/energy-wave.js?v=7";
let hostVersionLabel = "";
let productUpdateSnapshot = null;
let productUpdatePollTimer = null;
- const DEVICE_TOKEN_KEY = "vibeDeckDeviceToken";
- const LEGACY_DEVICE_TOKEN_KEY = "phoneMonitorDeviceToken";
- const DEVICE_ID_KEY = "vibeDeckDeviceId";
- const LEGACY_DEVICE_ID_KEY = "phoneMonitorDeviceId";
- const DEVICE_COOKIE = "VibeDeck-Device-Token";
- const LEGACY_DEVICE_COOKIE = "PhoneMonitor-Device-Token";
- const DEVICE_TOKEN_HISTORY_KEY = "vibeDeckDeviceTokenHistory.v1";
- const DEVICE_TOKEN_HISTORY_LIMIT = 4;
- const CLIENT_INSTANCE_KEY = "vibeDeckClientInstanceId";
- const LEGACY_CLIENT_INSTANCE_KEY = "phoneMonitorClientInstanceId";
- const CLIENT_INSTANCE_COOKIE = "VibeDeck-Client-Instance";
- const LEGACY_CLIENT_INSTANCE_COOKIE = "PhoneMonitor-Client-Instance";
const IPHONE_XS_EXACT_PRESET = "iphonexs-css-812x375";
const IPHONE_XS_ASPECT_VERSION = "1";
const DISPLAY_SOURCE_KEY = "vibeDeckDisplaySource.v1";
- function readCookie(name) {
- const parts = (`; ${document.cookie || ""}`).split(`; ${name}=`);
- if (parts.length < 2) return "";
- return decodeURIComponent(parts.pop().split(";").shift() || "");
- }
-
- function writeCookie(name, value, days) {
- const maxAge = Math.max(0, Math.floor(days * 24 * 60 * 60));
- const secure = location.protocol === "https:" ? "; Secure" : "";
- document.cookie = `${name}=${encodeURIComponent(value || "")}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secure}`;
- }
-
- function writeDeviceCookies(token, days) {
- writeCookie(DEVICE_COOKIE, token, days);
- writeCookie(LEGACY_DEVICE_COOKIE, token, days);
- }
-
- function loadStoredDeviceCredentials() {
- const token = localStorage.getItem(DEVICE_TOKEN_KEY)
- || localStorage.getItem(LEGACY_DEVICE_TOKEN_KEY)
- || sessionStorage.getItem(DEVICE_TOKEN_KEY)
- || sessionStorage.getItem(LEGACY_DEVICE_TOKEN_KEY)
- || readCookie(DEVICE_COOKIE)
- || readCookie(LEGACY_DEVICE_COOKIE)
- || "";
- const id = localStorage.getItem(DEVICE_ID_KEY)
- || localStorage.getItem(LEGACY_DEVICE_ID_KEY)
- || sessionStorage.getItem(DEVICE_ID_KEY)
- || sessionStorage.getItem(LEGACY_DEVICE_ID_KEY)
- || "";
- return { token, id };
- }
-
- function loadDeviceTokenHistory() {
- try {
- const values = JSON.parse(localStorage.getItem(DEVICE_TOKEN_HISTORY_KEY) || "[]");
- return Array.isArray(values)
- ? values.map(value => String(value || "").trim()).filter(Boolean).slice(0, DEVICE_TOKEN_HISTORY_LIMIT)
- : [];
- } catch {
- return [];
- }
- }
-
- function saveDeviceTokenHistory(values) {
- try {
- const unique = [...new Set(values.map(value => String(value || "").trim()).filter(Boolean))]
- .slice(0, DEVICE_TOKEN_HISTORY_LIMIT);
- if (unique.length) localStorage.setItem(DEVICE_TOKEN_HISTORY_KEY, JSON.stringify(unique));
- else localStorage.removeItem(DEVICE_TOKEN_HISTORY_KEY);
- } catch {
- // Token history is only a recovery path; normal cookie storage still works.
- }
- }
-
function persistDeviceCredentials(token, id) {
const nextToken = (token || "").trim();
const nextId = (id || "").trim();
@@ -470,25 +439,7 @@ import { createEnergyWave } from "./modules/energy-wave.js?v=7";
function isMobileClient() {
if (isDevicePreview()) return true;
- return isIos() || /Android|Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent || "");
- }
-
- const EINK_PREF_KEY = "phoneMonitorEink";
-
- function readEinkQuery() {
- const requested = new URLSearchParams(location.search).get("eink");
- if (requested === "1" || requested === "true") return true;
- if (requested === "0" || requested === "false") return false;
- return null;
- }
-
- function readEinkCookie() {
- try {
- const match = document.cookie.match(/(?:^|;\s*)phoneMonitorEink=([01])/);
- return match ? match[1] : null;
- } catch {
- return null;
- }
+ return isMobileUA();
}
function writeEinkPreference(value) {
@@ -508,38 +459,6 @@ import { createEnergyWave } from "./modules/energy-wave.js?v=7";
}
}
- function looksLikeBooxScreen() {
- // Product path is browser/PWA. NeoBrowser on Go Color often sends a generic
- // Android Chrome UA with no BOOX/ONYX token, so fall back to known panel size.
- try {
- const widths = [
- screen.width || 0,
- screen.height || 0,
- window.innerWidth || 0,
- window.innerHeight || 0,
- document.documentElement?.clientWidth || 0,
- document.documentElement?.clientHeight || 0
- ];
- const w = Math.max(...widths);
- const h = Math.min(...widths.filter(v => v > 0));
- // BOOX Go Color 7: 1680 × 1264 (allow CSS-pixel / density variance)
- if (w >= 1180 && w <= 1900 && h >= 980 && h <= 1500 && (w / Math.max(h, 1)) >= 1.15) {
- return true;
- }
- } catch {
- // ignore
- }
- return false;
- }
-
- function detectEinkHardware() {
- const ua = navigator.userAgent || "";
- if (/VibeDeck-EInk|BOOX|ONYX|Onyx|eInk|E-Ink|E Ink/i.test(ua)) return true;
- // Generic Android Chrome on a known BOOX panel still wants the paper layout.
- if (/Android/i.test(ua) && looksLikeBooxScreen()) return true;
- return false;
- }
-
function isEinkClient() {
if (isDevicePreview()) return isDevicePreview("boox-go-color-7");
const query = readEinkQuery();
@@ -812,13 +731,12 @@ import { createEnergyWave } from "./modules/energy-wave.js?v=7";
function isIos() {
if (isDevicePreview()) return isDevicePreview("iphone-xs");
- return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
- (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
+ return isIosUA();
}
function isIphone() {
if (isDevicePreview()) return isDevicePreview("iphone-xs");
- return /iPhone|iPod/.test(navigator.userAgent || "");
+ return isIphoneUA();
}
function prefersWebRtcDisplay() {
@@ -890,11 +808,6 @@ import { createEnergyWave } from "./modules/energy-wave.js?v=7";
return `https://${host}:5443${path}${search}${hash}`;
}
- function isLoopbackHost() {
- const host = location.hostname;
- return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
- }
-
/** iPhone single path: never use HTTP for the app UI. */
function enforceMobileHttpsPath() {
if (!isMobileClient() || isLoopbackHost()) {
@@ -1676,28 +1589,6 @@ import { createEnergyWave } from "./modules/energy-wave.js?v=7";
return navigator.platform || "Web device";
}
- function getOrCreateClientInstanceId() {
- let value = "";
- try {
- value = localStorage.getItem(CLIENT_INSTANCE_KEY)
- || localStorage.getItem(LEGACY_CLIENT_INSTANCE_KEY)
- || readCookie(CLIENT_INSTANCE_COOKIE)
- || readCookie(LEGACY_CLIENT_INSTANCE_COOKIE)
- || "";
- } catch { }
- if (!value) {
- value = crypto.randomUUID?.() || Array.from(crypto.getRandomValues(new Uint8Array(18)))
- .map(item => item.toString(16).padStart(2, "0")).join("");
- }
- try {
- localStorage.setItem(CLIENT_INSTANCE_KEY, value);
- localStorage.setItem(LEGACY_CLIENT_INSTANCE_KEY, value);
- } catch { }
- writeCookie(CLIENT_INSTANCE_COOKIE, value, 800);
- writeCookie(LEGACY_CLIENT_INSTANCE_COOKIE, value, 800);
- return value;
- }
-
async function resolvePairingDeviceInfo() {
if (resolvedPairingInfo) return resolvedPairingInfo;
let model = "";
diff --git a/src/PhoneMonitor.Host/wwwroot/modules/client-instance.js b/src/PhoneMonitor.Host/wwwroot/modules/client-instance.js
new file mode 100644
index 0000000..ae5485e
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/modules/client-instance.js
@@ -0,0 +1,31 @@
+// Client instance ID persistence, extracted verbatim from index.js.
+// Depends on readCookie/writeCookie from device-credentials.js.
+
+import { readCookie, writeCookie } from "./device-credentials.js?v=1";
+
+export const CLIENT_INSTANCE_KEY = "vibeDeckClientInstanceId";
+export const LEGACY_CLIENT_INSTANCE_KEY = "phoneMonitorClientInstanceId";
+export const CLIENT_INSTANCE_COOKIE = "VibeDeck-Client-Instance";
+export const LEGACY_CLIENT_INSTANCE_COOKIE = "PhoneMonitor-Client-Instance";
+
+export function getOrCreateClientInstanceId() {
+ let value = "";
+ try {
+ value = localStorage.getItem(CLIENT_INSTANCE_KEY)
+ || localStorage.getItem(LEGACY_CLIENT_INSTANCE_KEY)
+ || readCookie(CLIENT_INSTANCE_COOKIE)
+ || readCookie(LEGACY_CLIENT_INSTANCE_COOKIE)
+ || "";
+ } catch { }
+ if (!value) {
+ value = crypto.randomUUID?.() || Array.from(crypto.getRandomValues(new Uint8Array(18)))
+ .map(item => item.toString(16).padStart(2, "0")).join("");
+ }
+ try {
+ localStorage.setItem(CLIENT_INSTANCE_KEY, value);
+ localStorage.setItem(LEGACY_CLIENT_INSTANCE_KEY, value);
+ } catch { }
+ writeCookie(CLIENT_INSTANCE_COOKIE, value, 800);
+ writeCookie(LEGACY_CLIENT_INSTANCE_COOKIE, value, 800);
+ return value;
+}
diff --git a/src/PhoneMonitor.Host/wwwroot/modules/device-credentials.js b/src/PhoneMonitor.Host/wwwroot/modules/device-credentials.js
new file mode 100644
index 0000000..1ea6dab
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/modules/device-credentials.js
@@ -0,0 +1,68 @@
+// Device credential + cookie storage primitives, extracted verbatim from
+// index.js. These are pure helpers (no shared app state) — index.js imports
+// them and its call sites stay unchanged. The stateful persistDeviceCredentials,
+// which mutates the live deviceToken/deviceId, stays in index.js and calls these.
+
+export const DEVICE_TOKEN_KEY = "vibeDeckDeviceToken";
+export const LEGACY_DEVICE_TOKEN_KEY = "phoneMonitorDeviceToken";
+export const DEVICE_ID_KEY = "vibeDeckDeviceId";
+export const LEGACY_DEVICE_ID_KEY = "phoneMonitorDeviceId";
+export const DEVICE_COOKIE = "VibeDeck-Device-Token";
+export const LEGACY_DEVICE_COOKIE = "PhoneMonitor-Device-Token";
+export const DEVICE_TOKEN_HISTORY_KEY = "vibeDeckDeviceTokenHistory.v1";
+export const DEVICE_TOKEN_HISTORY_LIMIT = 4;
+
+export function readCookie(name) {
+ const parts = (`; ${document.cookie || ""}`).split(`; ${name}=`);
+ if (parts.length < 2) return "";
+ return decodeURIComponent(parts.pop().split(";").shift() || "");
+}
+
+export function writeCookie(name, value, days) {
+ const maxAge = Math.max(0, Math.floor(days * 24 * 60 * 60));
+ const secure = location.protocol === "https:" ? "; Secure" : "";
+ document.cookie = `${name}=${encodeURIComponent(value || "")}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secure}`;
+}
+
+export function writeDeviceCookies(token, days) {
+ writeCookie(DEVICE_COOKIE, token, days);
+ writeCookie(LEGACY_DEVICE_COOKIE, token, days);
+}
+
+export function loadStoredDeviceCredentials() {
+ const token = localStorage.getItem(DEVICE_TOKEN_KEY)
+ || localStorage.getItem(LEGACY_DEVICE_TOKEN_KEY)
+ || sessionStorage.getItem(DEVICE_TOKEN_KEY)
+ || sessionStorage.getItem(LEGACY_DEVICE_TOKEN_KEY)
+ || readCookie(DEVICE_COOKIE)
+ || readCookie(LEGACY_DEVICE_COOKIE)
+ || "";
+ const id = localStorage.getItem(DEVICE_ID_KEY)
+ || localStorage.getItem(LEGACY_DEVICE_ID_KEY)
+ || sessionStorage.getItem(DEVICE_ID_KEY)
+ || sessionStorage.getItem(LEGACY_DEVICE_ID_KEY)
+ || "";
+ return { token, id };
+}
+
+export function loadDeviceTokenHistory() {
+ try {
+ const values = JSON.parse(localStorage.getItem(DEVICE_TOKEN_HISTORY_KEY) || "[]");
+ return Array.isArray(values)
+ ? values.map(value => String(value || "").trim()).filter(Boolean).slice(0, DEVICE_TOKEN_HISTORY_LIMIT)
+ : [];
+ } catch {
+ return [];
+ }
+}
+
+export function saveDeviceTokenHistory(values) {
+ try {
+ const unique = [...new Set(values.map(value => String(value || "").trim()).filter(Boolean))]
+ .slice(0, DEVICE_TOKEN_HISTORY_LIMIT);
+ if (unique.length) localStorage.setItem(DEVICE_TOKEN_HISTORY_KEY, JSON.stringify(unique));
+ else localStorage.removeItem(DEVICE_TOKEN_HISTORY_KEY);
+ } catch {
+ // Token history is only a recovery path; normal cookie storage still works.
+ }
+}
diff --git a/src/PhoneMonitor.Host/wwwroot/modules/eink-detect.js b/src/PhoneMonitor.Host/wwwroot/modules/eink-detect.js
new file mode 100644
index 0000000..a929757
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/modules/eink-detect.js
@@ -0,0 +1,52 @@
+// E-ink hardware detection primitives, extracted verbatim from index.js.
+// These are pure helpers (no app-state dependency). Functions that depend on
+// isDevicePreview (isEinkClient, writeEinkPreference, ensureEinkPreferenceSticky)
+// stay in index.js and call these primitives.
+
+export const EINK_PREF_KEY = "phoneMonitorEink";
+
+export function readEinkQuery() {
+ const requested = new URLSearchParams(location.search).get("eink");
+ if (requested === "1" || requested === "true") return true;
+ if (requested === "0" || requested === "false") return false;
+ return null;
+}
+
+export function readEinkCookie() {
+ try {
+ const match = document.cookie.match(/(?:^|;\s*)phoneMonitorEink=([01])/);
+ return match ? match[1] : null;
+ } catch {
+ return null;
+ }
+}
+
+export function looksLikeBooxScreen() {
+ try {
+ const widths = [
+ screen.width || 0,
+ screen.height || 0,
+ window.innerWidth || 0,
+ window.innerHeight || 0,
+ document.documentElement?.clientWidth || 0,
+ document.documentElement?.clientHeight || 0
+ ];
+ const w = Math.max(...widths);
+ const h = Math.min(...widths.filter(v => v > 0));
+ // BOOX Go Color 7: 1680 × 1264 (allow CSS-pixel / density variance)
+ if (w >= 1180 && w <= 1900 && h >= 980 && h <= 1500 && (w / Math.max(h, 1)) >= 1.15) {
+ return true;
+ }
+ } catch {
+ // ignore
+ }
+ return false;
+}
+
+export function detectEinkHardware() {
+ const ua = navigator.userAgent || "";
+ if (/VibeDeck-EInk|BOOX|ONYX|Onyx|eInk|E-Ink|E Ink/i.test(ua)) return true;
+ // Generic Android Chrome on a known BOOX panel still wants the paper layout.
+ if (/Android/i.test(ua) && looksLikeBooxScreen()) return true;
+ return false;
+}
diff --git a/src/PhoneMonitor.Host/wwwroot/modules/energy-wave.js b/src/PhoneMonitor.Host/wwwroot/modules/energy-wave.js
index 21e4576..538279e 100644
--- a/src/PhoneMonitor.Host/wwwroot/modules/energy-wave.js
+++ b/src/PhoneMonitor.Host/wwwroot/modules/energy-wave.js
@@ -1,247 +1,6 @@
-const STRIP_COUNT = 24;
-const FRAME_INTERVAL = 1000 / 45;
+// Energy wave retired — the animated canvas effect caused high CPU usage on
+// RDP and low-GPU environments. The sideboard already has three selectable
+// background themes that are pure CSS (zero runtime cost).
+// ponytail: kept as no-op export so existing createEnergyWave() call doesn't break.
-function clamp(value, minimum, maximum) {
- return Math.min(maximum, Math.max(minimum, value));
-}
-
-function watchMedia(media, listener) {
- if (typeof media.addEventListener === "function") {
- media.addEventListener("change", listener);
- return;
- }
-
- media.addListener(listener);
-}
-
-export function createEnergyWave() {
- const root = document.getElementById("energyWaveBackdrop");
- const canvas = document.getElementById("energyWaveCanvas");
- const strips = document.getElementById("energyWaveStrips");
- if (!root || !canvas || !strips || root.dataset.energyWaveReady === "true") return;
-
- root.dataset.energyWaveReady = "true";
- for (let index = 0; index < STRIP_COUNT; index += 1) {
- const strip = document.createElement("span");
- strip.className = "energy-wave-strip";
- strips.append(strip);
- }
-
- const context = canvas.getContext("2d", { alpha: true, desynchronized: true });
- if (!context) return;
-
- const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
- const compactViewport = window.matchMedia("(max-width: 900px)");
- const motion = {
- x: 0.5,
- y: 0.5,
- targetX: 0.5,
- targetY: 0.5,
- directionX: 0,
- directionY: 0,
- targetDirectionX: 0,
- targetDirectionY: 0,
- };
- const lastPointer = { x: 0.5, y: 0.5, time: performance.now() };
- let bounds = { width: 0, height: 0, ratio: 1 };
- let animationFrame = 0;
- let lastFrame = 0;
- let running = false;
- let active = false;
-
- function canShow() {
- const desktopConsole = document.body.classList.contains("pc-console") && !compactViewport.matches;
- const deckWindow = document.body.classList.contains("deck-window");
- // Phone dashboards keep their selected Command / Dial / Focus skin static.
- // The continuously composited wave is reserved for PC surfaces.
- return document.body.classList.contains("mode-sideboard") &&
- !document.body.classList.contains("eink-client") &&
- (desktopConsole || deckWindow);
- }
-
- function resize() {
- const rect = root.getBoundingClientRect();
- const width = Math.max(1, Math.round(rect.width));
- const height = Math.max(1, Math.round(rect.height));
- const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
- if (width === bounds.width && height === bounds.height && ratio === bounds.ratio) return;
-
- bounds = { width, height, ratio };
- canvas.width = Math.round(width * ratio);
- canvas.height = Math.round(height * ratio);
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- }
-
- function trace(points, offsetX = 0, offsetY = 0) {
- context.beginPath();
- context.moveTo(points.start.x + offsetX, points.start.y + offsetY);
- context.bezierCurveTo(
- points.controlOne.x + offsetX,
- points.controlOne.y + offsetY,
- points.controlTwo.x + offsetX,
- points.controlTwo.y + offsetY,
- points.end.x + offsetX,
- points.end.y + offsetY,
- );
- }
-
- function paint(points, width, color, blur = 0, offsetX = 0, offsetY = 0) {
- context.save();
- context.lineCap = "round";
- context.lineJoin = "round";
- context.lineWidth = width;
- context.strokeStyle = color;
- context.filter = blur > 0 ? `blur(${blur}px)` : "none";
- context.shadowBlur = blur > 0 ? blur * 1.45 : 0;
- context.shadowColor = color;
- trace(points, offsetX, offsetY);
- context.stroke();
- context.restore();
- }
-
- function getPoints(time) {
- const { width, height } = bounds;
- const breathing = 0.58 + Math.sin(time * 0.82) * 0.42;
- const flow = time * 0.56;
- const focusX = motion.x - 0.5;
- const focusY = motion.y - 0.5;
- const responseX = motion.directionX;
- const responseY = motion.directionY;
-
- return {
- start: { x: -width * 0.08, y: height * 0.62 },
- controlOne: {
- x: width * (0.24 + Math.sin(flow * 1.05) * 0.035 + focusX * 0.06 + responseX * 0.05),
- y: height * (0.7 + Math.sin(flow * 0.74) * 0.13 * breathing + focusY * 0.16 + responseY * 0.08),
- },
- controlTwo: {
- x: width * (0.71 + Math.cos(flow * 0.92) * 0.035 + focusX * 0.08 + responseX * 0.065),
- y: height * (0.3 + Math.sin(flow * 1.1 + 1.15) * 0.14 * breathing + focusY * 0.18 - responseY * 0.1),
- },
- end: { x: width * 1.08, y: height * 0.49 },
- };
- }
-
- function render(now) {
- if (!bounds.width || !bounds.height) return;
- const { width, height, ratio } = bounds;
- const time = now / 1000;
- const glowScale = clamp(width / 1180, 0.72, 1.22);
- const pulse = 0.72 + Math.sin(time * 1.35) * 0.12;
- const points = getPoints(time);
-
- context.setTransform(1, 0, 0, 1, 0, 0);
- context.clearRect(0, 0, canvas.width, canvas.height);
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- context.save();
- context.globalCompositeOperation = "lighter";
-
- paint(points, 170 * glowScale, "rgba(31, 102, 255, 0.055)", 36 * glowScale);
- paint(points, 104 * glowScale, "rgba(132, 78, 255, 0.11)", 25 * glowScale);
- paint(points, 56 * glowScale, "rgba(66, 220, 255, 0.19)", 15 * glowScale);
- paint(points, 30 * glowScale, "rgba(255, 126, 224, 0.29)", 8 * glowScale);
- paint(points, 14 * glowScale, `rgba(149, 242, 255, ${0.46 + pulse * 0.18})`, 3 * glowScale);
- paint(points, 4.4 * glowScale, `rgba(255, 252, 238, ${0.84 + pulse * 0.14})`);
-
- const colors = ["rgba(91, 233, 255, 0.64)", "rgba(255, 104, 220, 0.55)", "rgba(255, 196, 93, 0.58)"];
- const stripWidth = width / STRIP_COUNT;
- for (let index = 0; index < STRIP_COUNT; index += 1) {
- const edge = index * stripWidth;
- const displacement = (index % 5 - 2) * 1.8 + motion.directionX * 8;
- context.save();
- context.beginPath();
- context.rect(edge, 0, stripWidth + 1, height);
- context.clip();
- paint(points, 5.2 * glowScale, colors[index % colors.length], 0, displacement, motion.directionY * 8);
- context.restore();
- }
-
- context.restore();
- }
-
- function settle(delta) {
- const easing = 1 - Math.exp(-delta * 7);
- motion.x += (motion.targetX - motion.x) * easing;
- motion.y += (motion.targetY - motion.y) * easing;
- motion.directionX += (motion.targetDirectionX - motion.directionX) * easing;
- motion.directionY += (motion.targetDirectionY - motion.directionY) * easing;
- const decay = Math.exp(-delta * 3.4);
- motion.targetDirectionX *= decay;
- motion.targetDirectionY *= decay;
- }
-
- function loop(now) {
- if (!running) return;
- animationFrame = window.requestAnimationFrame(loop);
- if (now - lastFrame < FRAME_INTERVAL) return;
- const delta = Math.min(0.05, Math.max(0.001, (now - lastFrame) / 1000));
- lastFrame = now;
- settle(delta);
- render(now);
- }
-
- function start() {
- if (running) return;
- running = true;
- lastFrame = performance.now();
- animationFrame = window.requestAnimationFrame(loop);
- }
-
- function stop() {
- if (!running) return;
- running = false;
- window.cancelAnimationFrame(animationFrame);
- animationFrame = 0;
- }
-
- function sync() {
- active = canShow();
- root.hidden = !active;
- root.classList.toggle("is-active", active);
- document.body.classList.toggle("energy-wave-active", active);
- if (!active) {
- stop();
- return;
- }
-
- resize();
- render(performance.now());
- if (!reduceMotion.matches && !document.hidden) start();
- else stop();
- }
-
- function updatePointer(event) {
- if (!active || reduceMotion.matches) return;
- const rect = root.getBoundingClientRect();
- const x = clamp((event.clientX - rect.left) / Math.max(rect.width, 1), 0, 1);
- const y = clamp((event.clientY - rect.top) / Math.max(rect.height, 1), 0, 1);
- const now = performance.now();
- const elapsed = Math.max(16, now - lastPointer.time);
- motion.targetX = x;
- motion.targetY = y;
- motion.targetDirectionX = clamp(((x - lastPointer.x) * 1000) / elapsed, -1, 1);
- motion.targetDirectionY = clamp(((y - lastPointer.y) * 1000) / elapsed, -1, 1);
- lastPointer.x = x;
- lastPointer.y = y;
- lastPointer.time = now;
- }
-
- function resetPointer() {
- motion.targetX = 0.5;
- motion.targetY = 0.5;
- motion.targetDirectionX = 0;
- motion.targetDirectionY = 0;
- }
-
- const observer = new MutationObserver(sync);
- observer.observe(document.body, { attributes: true, attributeFilter: ["class"] });
- const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
- resizeObserver?.observe(root);
- window.addEventListener("pointermove", updatePointer, { passive: true });
- window.addEventListener("blur", resetPointer);
- window.addEventListener("resize", sync, { passive: true });
- document.addEventListener("visibilitychange", sync);
- watchMedia(reduceMotion, sync);
- watchMedia(compactViewport, sync);
- sync();
-}
+export function createEnergyWave() {}
diff --git a/src/PhoneMonitor.Host/wwwroot/modules/env-detect.js b/src/PhoneMonitor.Host/wwwroot/modules/env-detect.js
new file mode 100644
index 0000000..3ae5535
--- /dev/null
+++ b/src/PhoneMonitor.Host/wwwroot/modules/env-detect.js
@@ -0,0 +1,22 @@
+// Environment detection pure helpers, extracted verbatim from index.js.
+// These have zero app-state dependencies (only read location/navigator).
+// index.js wraps some of these with device-preview overrides; these are
+// the underlying detection primitives.
+
+export function isLoopbackHost() {
+ const host = location.hostname;
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
+}
+
+export function isIosUA() {
+ return /iPad|iPhone|iPod/.test(navigator.userAgent) ||
+ (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
+}
+
+export function isIphoneUA() {
+ return /iPhone|iPod/.test(navigator.userAgent || "");
+}
+
+export function isMobileUA() {
+ return isIosUA() || /Android|Mobile|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent || "");
+}
diff --git a/tests/PhoneMonitor.Host.Tests/DeviceTrustPairingFlowTests.cs b/tests/PhoneMonitor.Host.Tests/DeviceTrustPairingFlowTests.cs
new file mode 100644
index 0000000..ace8205
--- /dev/null
+++ b/tests/PhoneMonitor.Host.Tests/DeviceTrustPairingFlowTests.cs
@@ -0,0 +1,196 @@
+using System;
+using System.IO;
+using PhoneMonitor.Host.Security;
+using Xunit;
+
+namespace PhoneMonitor.Host.Tests
+{
+ // Guardrail tests that pin the *current* pairing state-machine behavior of
+ // DeviceTrustService before the auth/pairing vertical is refactored out of
+ // Startup.cs. Service-level (no HTTP host), matching the project's existing
+ // test style. Any behavior drift (deny path, poll one-shot semantics, pending
+ // list, revoke/clear, token-based trust) should turn these red.
+ public sealed class DeviceTrustPairingFlowTests : IDisposable
+ {
+ private readonly string root = Path.Combine(Path.GetTempPath(), "VibeDeck-tests", Guid.NewGuid().ToString("N"));
+
+ private const string UserAgent = "Mozilla/5.0 (Linux; Android 10; K) Chrome/150 Mobile";
+ private const string Address = "192.168.0.24";
+
+ private DeviceTrustService NewService() => new DeviceTrustService(root);
+
+ private PairingApprovalRequestResult Request(DeviceTrustService service, string clientId)
+ => service.RequestApproval("Android 裝置", "Android", "SM-S9110", clientId, UserAgent, Address);
+
+ [Fact]
+ public void Poll_before_decision_returns_pending_without_token()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-aaaaaaaaaa");
+
+ var poll = service.PollApproval(request.RequestId, request.RequestSecret);
+
+ Assert.True(poll.Success);
+ Assert.Equal("pending", poll.Status);
+ Assert.Null(poll.DeviceToken);
+ }
+
+ [Fact]
+ public void Poll_with_wrong_secret_fails()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-bbbbbbbbbb");
+
+ var poll = service.PollApproval(request.RequestId, "not-the-secret");
+
+ Assert.False(poll.Success);
+ }
+
+ [Fact]
+ public void Deny_marks_denied_and_device_never_trusted()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-cccccccccc");
+
+ Assert.True(service.DenyRequest(request.RequestId).Success);
+ var poll = service.PollApproval(request.RequestId, request.RequestSecret);
+
+ Assert.True(poll.Success);
+ Assert.Equal("denied", poll.Status);
+ Assert.Null(poll.DeviceToken);
+ Assert.Equal(0, service.GetStatus(null, "127.0.0.1", "test", true, false).PairedDeviceCount);
+ }
+
+ [Fact]
+ public void Approved_poll_is_one_shot()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-dddddddddd");
+ Assert.True(service.ApproveRequest(request.RequestId).Success);
+
+ var first = service.PollApproval(request.RequestId, request.RequestSecret);
+ var second = service.PollApproval(request.RequestId, request.RequestSecret);
+
+ Assert.True(first.Success);
+ Assert.Equal("approved", first.Status);
+ Assert.False(string.IsNullOrEmpty(first.DeviceToken));
+ Assert.False(second.Success); // pending entry is consumed after an approved/denied poll
+ }
+
+ [Fact]
+ public void Denied_poll_is_one_shot()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-eeeeeeeeee");
+ Assert.True(service.DenyRequest(request.RequestId).Success);
+
+ var first = service.PollApproval(request.RequestId, request.RequestSecret);
+ var second = service.PollApproval(request.RequestId, request.RequestSecret);
+
+ Assert.True(first.Success);
+ Assert.Equal("denied", first.Status);
+ Assert.False(second.Success);
+ }
+
+ [Fact]
+ public void Pending_list_tracks_requests_and_clears_after_a_decision()
+ {
+ var service = NewService();
+ var a = Request(service, "client-instance-ff11111111");
+ var b = Request(service, "client-instance-ff22222222");
+
+ Assert.Equal(2, service.GetPendingApprovals().Count);
+
+ Assert.True(service.ApproveRequest(a.RequestId).Success);
+ Assert.True(service.DenyRequest(b.RequestId).Success);
+
+ Assert.Empty(service.GetPendingApprovals());
+ }
+
+ [Fact]
+ public void RequestApproval_is_idempotent_for_the_same_client()
+ {
+ var service = NewService();
+ var first = Request(service, "client-instance-gggggggggg");
+ var second = Request(service, "client-instance-gggggggggg");
+
+ Assert.Equal(first.RequestId, second.RequestId);
+ Assert.Single(service.GetPendingApprovals());
+ }
+
+ [Fact]
+ public void Approve_unknown_or_already_decided_request_fails()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-hhhhhhhhhh");
+
+ Assert.False(service.ApproveRequest("no-such-request").Success);
+ Assert.True(service.ApproveRequest(request.RequestId).Success);
+ Assert.False(service.ApproveRequest(request.RequestId).Success); // no longer pending
+ }
+
+ [Fact]
+ public void IsTrusted_is_true_for_paired_token_and_false_otherwise()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-iiiiiiiiii");
+ Assert.True(service.ApproveRequest(request.RequestId).Success);
+ var poll = service.PollApproval(request.RequestId, request.RequestSecret);
+
+ Assert.True(service.IsTrusted(poll.DeviceToken, Address, UserAgent));
+ Assert.False(service.IsTrusted("bogus-token", Address, UserAgent));
+ Assert.False(service.IsTrusted(null, Address, UserAgent));
+ }
+
+ [Fact]
+ public void Revoke_removes_trust_for_that_device()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-jjjjjjjjjj");
+ Assert.True(service.ApproveRequest(request.RequestId).Success);
+ var poll = service.PollApproval(request.RequestId, request.RequestSecret);
+ Assert.True(service.IsTrusted(poll.DeviceToken, Address, UserAgent));
+
+ Assert.True(service.RevokeDevice(poll.DeviceId).Success);
+
+ Assert.False(service.IsTrusted(poll.DeviceToken, Address, UserAgent));
+ Assert.Equal(0, service.GetStatus(null, "127.0.0.1", "test", true, false).PairedDeviceCount);
+ }
+
+ [Fact]
+ public void Revoke_unknown_or_blank_device_fails()
+ {
+ var service = NewService();
+
+ Assert.False(service.RevokeDevice("no-such-device").Success);
+ Assert.False(service.RevokeDevice("").Success);
+ }
+
+ [Fact]
+ public void Clear_removes_all_devices_and_pending_requests()
+ {
+ var service = NewService();
+ var request = Request(service, "client-instance-kkkkkkkkkk");
+ Assert.True(service.ApproveRequest(request.RequestId).Success);
+ var poll = service.PollApproval(request.RequestId, request.RequestSecret);
+ Request(service, "client-instance-llllllllll"); // an extra pending request
+
+ Assert.True(service.ClearDevices().Success);
+
+ Assert.False(service.IsTrusted(poll.DeviceToken, Address, UserAgent));
+ Assert.Equal(0, service.GetStatus(null, "127.0.0.1", "test", true, false).PairedDeviceCount);
+ Assert.Empty(service.GetPendingApprovals());
+ }
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(root)) Directory.Delete(root, true);
+ }
+ catch
+ {
+ }
+ }
+ }
+}