From 582415f08e98b4386a012678c3543e0bb4e08e93 Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 16:41:54 +0800 Subject: [PATCH 1/9] refactor: split low-risk endpoint groups out of Startup.cs + add host architecture map Extract display/deck, system (health/capabilities), onboarding assets (qr + certs), sideboard, and quota endpoints from the Startup.cs UseEndpoints block into dedicated Startup.*.cs partials. Pure move: identical paths, auth gates, services, DTOs, status codes and JSON. Security vertical (auth/connect/session/devices+pairing), streaming, dashboard SSE and root stay inline pending test seams (see docs/technical-debt-roadmap.md). Startup.cs 1659->1239 lines; 62/62 Host tests pass. Add docs/host-endpoint-architecture.md: middleware pipeline order, endpoint-group to service map, and the five Require* authorization gates. --- docs/host-endpoint-architecture.md | 164 ++++++ src/PhoneMonitor.Host/Startup.Display.cs | 165 ++++++ .../Startup.OnboardingAssets.cs | 77 +++ src/PhoneMonitor.Host/Startup.Quotas.cs | 259 ++++++++++ src/PhoneMonitor.Host/Startup.Sideboard.cs | 52 ++ src/PhoneMonitor.Host/Startup.System.cs | 38 ++ src/PhoneMonitor.Host/Startup.cs | 488 +----------------- 7 files changed, 760 insertions(+), 483 deletions(-) create mode 100644 docs/host-endpoint-architecture.md create mode 100644 src/PhoneMonitor.Host/Startup.Display.cs create mode 100644 src/PhoneMonitor.Host/Startup.OnboardingAssets.cs create mode 100644 src/PhoneMonitor.Host/Startup.Quotas.cs create mode 100644 src/PhoneMonitor.Host/Startup.Sideboard.cs create mode 100644 src/PhoneMonitor.Host/Startup.System.cs diff --git a/docs/host-endpoint-architecture.md b/docs/host-endpoint-architecture.md new file mode 100644 index 0000000..16bce46 --- /dev/null +++ b/docs/host-endpoint-architecture.md @@ -0,0 +1,164 @@ +# VibeDeck Host 後端架構圖(Startup 管線 + 端點群 + 授權) + +盤點日期:2026-07-22 +基準版本:`refactor/startup-display-endpoints`(源自 `master` / 0.1.36) + +這份文件把 `PhoneMonitor.Host` 的 HTTP/WebSocket 後端講清楚:**中介軟體管線順序**、**端點依責任分成哪些群(各自一個 `Startup.*.cs` partial)**、**每群呼叫哪些服務**、以及**每個端點套哪一層授權 gate**。目的是消除「所有東西擠在 `Startup.cs`」的義大利麵,讓後續改動能看圖定位。 + +`Startup` 是一個 `partial class`,拆成多個檔案,但編譯期仍是同一個型別;共用的私有 helper(`Require*`、`WriteAudit`、`SocketJsonOptions`、各種 `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"] + CUSTOM["MapCustomSourceEndpoints
Startup.CustomSources.cs"] + DASHL["MapDashboardLayoutEndpoints
Startup.DashboardLayout.cs"] + DIAG["MapDiagnosticsEndpoints
Startup.Diagnostics.cs"] + STR["MapStreamTransportEndpoints
Startup.StreamTransport.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/*"] + WS["Streaming /ws/display · /api/stream/webrtc/offer · /ws/input"] + 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"] + 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"] + WS --> SVC_WS["DisplayFrameSource / WebRtcH264Service / WindowsInputController"] + 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 | +| `MapCustomSourceEndpoints` | `/api/custom-sources/*` | `CustomSourceService` / `CustomSourceStore` | 見該 partial | +| `MapDashboardLayoutEndpoints` | `/api/dashboard/layout*` | `DashboardLayoutService` | 讀=TrustedDevice;寫=ProtectedAction | +| `MapDiagnosticsEndpoints` | 診斷 / 稽核 | 診斷服務 | 見該 partial | +| `MapStreamTransportEndpoints` | 串流傳輸協商 | 串流服務 | 見該 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 | +| (內聯)Streaming | `/ws/display`, `/api/stream/webrtc/offer`, `/ws/input` | `DisplayFrameSource`, `WebRtcH264Service`, `WindowsInputController` | TrustedDevice | +| (內聯)Dashboard SSE | `/api/dashboard/events` | `DashboardEventHub` | TrustedDevice | +| (內聯)Root | `/` | 靜態 `index.html` + AGY OAuth callback | 無 | + +--- + +## 3. 授權 gate(安全層) + +授權集中在 5 個 `Require*Async` helper(都在 `Startup.cs`),端點在委派最前面呼叫。層級由鬆到嚴: + +```mermaid +flowchart TD + LOCALQ{"IsLocalRequest?
(loopback / 本機)"} + + 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、串流)。 +- **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`,**必須先補完整的配對狀態機與端點整合測試,才能拆**,不在低風險機械搬移範圍。 +- **串流(/ws/display、webrtc/offer、/ws/input)**:委派閉包了 `Startup` 的私有 helper(`StreamDisplayAsync`、`ReceiveInputAsync`),屬耦合較重的串流垂直,另案處理。 +- **Root `/`**:委派閉包了 `Configure` 的區域變數 `env`(`env.WebRootPath`),移出需另傳參數,非純機械搬移。 +- **共用 helper**:`Require*`、`WriteAudit`、`SocketJsonOptions`、`WriteStreamCapabilitiesAsync`、`WriteGlanceBoardResponseAsync`、`WriteQrSvgAsync`/`BuildQrSvg`、`WriteCertificateFileAsync` 等被多群共用,留在 `Startup.cs` 當共用層。 + +--- + +## 5. 本波重構做了什麼(純搬移、對外契約不變) + +從 `Startup.cs` 外移到新 partial(路徑、授權 gate、服務、DTO、狀態碼、JSON 全部不變): + +| 新檔 | 內容 | +|---|---| +| `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.cs` 1659 → 1239 行;62/62 測試維持綠燈。實機 `scripts/test-product-flow.ps1 -Installed` 建議在合併前於安裝環境跑一次作為關卡。 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.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..72d9c2e 100644 --- a/src/PhoneMonitor.Host/Startup.cs +++ b/src/PhoneMonitor.Host/Startup.cs @@ -258,21 +258,11 @@ 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); endpoints.MapGet("/api/auth/status", async context => { @@ -473,12 +463,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,450 +560,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) @@ -1071,24 +611,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; From defafe8fd8da01d3acac96e0547aacd4ee5cc965 Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 16:46:31 +0800 Subject: [PATCH 2/9] refactor: move live streaming endpoints into Startup.Streaming.cs Move /ws/display, /api/stream/webrtc/offer and /ws/input from Startup.cs into a new MapStreamingEndpoints in Startup.Streaming.cs, co-located with the StreamDisplayAsync/ReceiveInputAsync runtime loops they call. Pure move: same paths, TrustedDevice gate, services, status codes and JSON. ICE/TURN setup stays in MapStreamTransportEndpoints. 62/62 Host tests pass. --- src/PhoneMonitor.Host/Startup.Streaming.cs | 98 ++++++++++++++++++++++ src/PhoneMonitor.Host/Startup.cs | 87 +------------------ 2 files changed, 99 insertions(+), 86 deletions(-) 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.cs b/src/PhoneMonitor.Host/Startup.cs index 72d9c2e..2dc408d 100644 --- a/src/PhoneMonitor.Host/Startup.cs +++ b/src/PhoneMonitor.Host/Startup.cs @@ -263,6 +263,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) MapOnboardingAssetEndpoints(endpoints); MapSideboardEndpoints(endpoints); MapQuotaEndpoints(endpoints); + MapStreamingEndpoints(endpoints); endpoints.MapGet("/api/auth/status", async context => { @@ -560,27 +561,6 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(devices.GetStatus( await context.Response.WriteAsync(JsonSerializer.Serialize(result)); }); - 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; @@ -731,71 +711,6 @@ await context.Response.WriteAsync(JsonSerializer.Serialize(devices.GetStatus( } }); - 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)) From 41c18c374652acde7661513c78acc45a670d24bf Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 16:49:36 +0800 Subject: [PATCH 3/9] docs: update host architecture map for streaming endpoint extraction --- docs/host-endpoint-architecture.md | 39 +++++++++++++++--------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/docs/host-endpoint-architecture.md b/docs/host-endpoint-architecture.md index 16bce46..77612c2 100644 --- a/docs/host-endpoint-architecture.md +++ b/docs/host-endpoint-architecture.md @@ -1,11 +1,11 @@ # VibeDeck Host 後端架構圖(Startup 管線 + 端點群 + 授權) 盤點日期:2026-07-22 -基準版本:`refactor/startup-display-endpoints`(源自 `master` / 0.1.36) +基準版本:`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`、各種 `Write*Async`)留在 `Startup.cs`,各 partial 直接呼叫。 +`Startup` 是一個 `partial class`,拆成多個檔案,但編譯期仍是同一個型別;共用的私有 helper(`Require*`、`WriteAudit`、`SocketJsonOptions`、`ParseInt`、各種 `Write*Async`)留在 `Startup.cs`,各 partial 直接呼叫。 --- @@ -32,7 +32,7 @@ flowchart TD ## 2. 端點群 → partial 檔 → 服務 -`UseEndpoints` 內先呼叫各群的 `Map*Endpoints(endpoints)`,再保留安全垂直與串流等仍內聯的端點。 +`UseEndpoints` 內先呼叫各群的 `Map*Endpoints(endpoints)`,再保留安全垂直等仍內聯的端點。 ```mermaid flowchart LR @@ -42,20 +42,20 @@ flowchart LR 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"] - STR["MapStreamTransportEndpoints
Startup.StreamTransport.cs"] UPD["MapProductUpdateEndpoints
Startup.ProductUpdates.cs"] NOTE["MapWindowsNotificationEndpoints
Startup.WindowsNotifications.cs"] end - subgraph CORE["仍內聯在 Startup.cs 的核心(本波不動)"] + subgraph CORE["仍內聯在 Startup.cs 的核心(本波刻意不動)"] AUTH["Auth /api/auth/*"] CONN["Connect /api/connect*"] SESS["Session /api/session"] DEVP["Devices + Pairing /api/devices/*"] - WS["Streaming /ws/display · /api/stream/webrtc/offer · /ws/input"] SSE["Dashboard SSE /api/dashboard/events"] ROOT["Root / (index.html + AGY OAuth callback)"] end @@ -66,6 +66,7 @@ flowchart LR 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"] @@ -73,7 +74,6 @@ flowchart LR CONN --> SVC_CONNSVC["ConnectInfoProvider / PublicEndpointService / ConnectionCodeBrokerService"] SESS --> SVC_SESS["ActionTokenService + DeviceTrustService + HostAccessAuthService"] DEVP --> SVC_DEV["DeviceTrustService"] - WS --> SVC_WS["DisplayFrameSource / WebRtcH264Service / WindowsInputController"] SSE --> SVC_SSE["DashboardEventHub"] ``` @@ -86,17 +86,17 @@ flowchart LR | `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 | -| `MapStreamTransportEndpoints` | 串流傳輸協商 | 串流服務 | 見該 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 | -| (內聯)Streaming | `/ws/display`, `/api/stream/webrtc/offer`, `/ws/input` | `DisplayFrameSource`, `WebRtcH264Service`, `WindowsInputController` | TrustedDevice | | (內聯)Dashboard SSE | `/api/dashboard/events` | `DashboardEventHub` | TrustedDevice | | (內聯)Root | `/` | 靜態 `index.html` + AGY OAuth callback | 無 | @@ -108,8 +108,6 @@ flowchart LR ```mermaid flowchart TD - LOCALQ{"IsLocalRequest?
(loopback / 本機)"} - subgraph G1["RequireTrustedDevice(唯讀存取)"] direction TB T1["本機 → 放行"] @@ -133,7 +131,7 @@ flowchart TD 一句話對應: - **公開**:`/health`、`/api/stream/capabilities`、`/qr.svg`、`/cert/*`、`/`、`/api/auth/status`。 -- **RequireTrustedDevice**:唯讀資料(displays、sideboard、quotas、dashboard events、串流)。 +- **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。 @@ -142,23 +140,26 @@ flowchart TD ## 4. 為什麼這些仍留在 Startup.cs(本波刻意不動) -- **安全垂直(Auth / Connect / Session / Devices+Pairing)**:這是結構債報告排序 #1(耦合度 5)。它跨 token 格式、cookie、Cloudflare Worker 連線碼、pending/approved 狀態機與持久化。依 `technical-debt-roadmap.md`,**必須先補完整的配對狀態機與端點整合測試,才能拆**,不在低風險機械搬移範圍。 -- **串流(/ws/display、webrtc/offer、/ws/input)**:委派閉包了 `Startup` 的私有 helper(`StreamDisplayAsync`、`ReceiveInputAsync`),屬耦合較重的串流垂直,另案處理。 +- **安全垂直(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`、`WriteStreamCapabilitiesAsync`、`WriteGlanceBoardResponseAsync`、`WriteQrSvgAsync`/`BuildQrSvg`、`WriteCertificateFileAsync` 等被多群共用,留在 `Startup.cs` 當共用層。 +- **共用 helper**:`Require*`、`WriteAudit`、`SocketJsonOptions`、`ParseInt`、`WriteStreamCapabilitiesAsync`、`WriteGlanceBoardResponseAsync`、`WriteQrSvgAsync`/`BuildQrSvg`、`WriteCertificateFileAsync`、`WriteAgyOAuthCallbackAsync` 等被多群共用,留在 `Startup.cs` 當共用層。 --- -## 5. 本波重構做了什麼(純搬移、對外契約不變) +## 5. 已外移的端點群一覽(純搬移、對外契約不變) -從 `Startup.cs` 外移到新 partial(路徑、授權 gate、服務、DTO、狀態碼、JSON 全部不變): +從 `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 測試維持全綠。 -`Startup.cs` 1659 → 1239 行;62/62 測試維持綠燈。實機 `scripts/test-product-flow.ps1 -Installed` 建議在合併前於安裝環境跑一次作為關卡。 +最終驗收:重構完成後,在本機用 `scripts/package-windows-setup.ps1` 打包安裝並實跑(配對、串流、display、quota),作為對外行為未變的實機關卡。 From cb98d9b6e8af593aeec47613fbd2e274cb3eb872 Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 17:14:10 +0800 Subject: [PATCH 4/9] test: pin DeviceTrustService pairing state-machine before auth refactor Add DeviceTrustPairingFlowTests covering deny path, poll pending/one-shot semantics, pending-approval list, request idempotency, approve-of-unknown/decided, token-based IsTrusted, revoke and clear. Service-level guardrails (no HTTP host, matching existing style) so the auth/pairing vertical can be split out of Startup.cs with a regression net. 74/74 tests pass. --- .../DeviceTrustPairingFlowTests.cs | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/PhoneMonitor.Host.Tests/DeviceTrustPairingFlowTests.cs 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 + { + } + } + } +} From d24b2b6ba8c1e356223e244589472832d98965a8 Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 17:37:25 +0800 Subject: [PATCH 5/9] refactor: split index.css into ordered @import partials index.css becomes a 6-line @import manifest loading css/00-base-tokens, 10-core, 20-pairing, 30-phone-dashboard, 40-eink-sideboard, 50-activity in the original cascade order. Verified byte-identical: concatenating the partials in order reproduces the original index.css exactly, and each partial is brace-balanced (no rule cut mid-body). No index.html change (single still loads index.css); service worker is retired and there is no CSP, so @import load order == original cascade. Zero visual change by construction; large 10-core.css blob has no safe internal seam and is left for a later pass. --- .../wwwroot/css/00-base-tokens.css | 97 + src/PhoneMonitor.Host/wwwroot/css/10-core.css | 4673 ++++++++++ .../wwwroot/css/20-pairing.css | 146 + .../wwwroot/css/30-phone-dashboard.css | 1185 +++ .../wwwroot/css/40-eink-sideboard.css | 629 ++ .../wwwroot/css/50-activity.css | 1143 +++ src/PhoneMonitor.Host/wwwroot/index.css | 7882 +---------------- 7 files changed, 7882 insertions(+), 7873 deletions(-) create mode 100644 src/PhoneMonitor.Host/wwwroot/css/00-base-tokens.css create mode 100644 src/PhoneMonitor.Host/wwwroot/css/10-core.css create mode 100644 src/PhoneMonitor.Host/wwwroot/css/20-pairing.css create mode 100644 src/PhoneMonitor.Host/wwwroot/css/30-phone-dashboard.css create mode 100644 src/PhoneMonitor.Host/wwwroot/css/40-eink-sideboard.css create mode 100644 src/PhoneMonitor.Host/wwwroot/css/50-activity.css 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"); From 7ad2b46587cb68f32deefd75fe85cfbf4e7a6b78 Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 17:44:00 +0800 Subject: [PATCH 6/9] refactor: extract device credential/cookie primitives from index.js Move the pure cookie + device-token storage helpers (readCookie, writeCookie, writeDeviceCookies, loadStoredDeviceCredentials, load/saveDeviceTokenHistory) and their key constants verbatim into modules/device-credentials.js and import them back. Call sites are unchanged (ES import binds the same names). The stateful persistDeviceCredentials (mutates live deviceToken/deviceId) stays in index.js. Verified: node --check on both files, zero leftover definitions in index.js, 14 exports == 14 imports. Behavior confirmed at install-run (no frontend test harness). --- src/PhoneMonitor.Host/wwwroot/index.js | 79 ++++--------------- .../wwwroot/modules/device-credentials.js | 68 ++++++++++++++++ 2 files changed, 84 insertions(+), 63 deletions(-) create mode 100644 src/PhoneMonitor.Host/wwwroot/modules/device-credentials.js diff --git a/src/PhoneMonitor.Host/wwwroot/index.js b/src/PhoneMonitor.Host/wwwroot/index.js index 77a536c..8480e8b 100644 --- a/src/PhoneMonitor.Host/wwwroot/index.js +++ b/src/PhoneMonitor.Host/wwwroot/index.js @@ -30,6 +30,22 @@ 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"; const screen = document.getElementById("screen"); const statusText = document.getElementById("status"); @@ -280,14 +296,6 @@ 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"; @@ -296,61 +304,6 @@ import { createEnergyWave } from "./modules/energy-wave.js?v=7"; 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(); 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. + } +} From 0605cec3e0422565eefa45f2b9bc4395c0f0909d Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 18:07:50 +0800 Subject: [PATCH 7/9] refactor: extract client-instance, env-detect, eink-detect modules from index.js client-instance.js: CLIENT_INSTANCE constants + getOrCreateClientInstanceId (imports readCookie/writeCookie from device-credentials). env-detect.js: isLoopbackHost + isIosUA/isIphoneUA/isMobileUA (pure UA/location primitives). eink-detect.js: EINK_PREF_KEY + readEinkQuery/readEinkCookie/looksLikeBooxScreen/detectEinkHardware. index.js wrappers (isIos/isIphone/isMobileClient) now delegate to env-detect primitives while keeping the devicePreview override path. node --check all 5 files pass, dev-run probes 200. --- src/PhoneMonitor.Host/wwwroot/index.js | 108 ++++-------------- .../wwwroot/modules/client-instance.js | 31 +++++ .../wwwroot/modules/eink-detect.js | 52 +++++++++ .../wwwroot/modules/env-detect.js | 22 ++++ 4 files changed, 128 insertions(+), 85 deletions(-) create mode 100644 src/PhoneMonitor.Host/wwwroot/modules/client-instance.js create mode 100644 src/PhoneMonitor.Host/wwwroot/modules/eink-detect.js create mode 100644 src/PhoneMonitor.Host/wwwroot/modules/env-detect.js diff --git a/src/PhoneMonitor.Host/wwwroot/index.js b/src/PhoneMonitor.Host/wwwroot/index.js index 8480e8b..8d6fca3 100644 --- a/src/PhoneMonitor.Host/wwwroot/index.js +++ b/src/PhoneMonitor.Host/wwwroot/index.js @@ -46,6 +46,26 @@ import { 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"); @@ -296,10 +316,6 @@ import { let hostVersionLabel = ""; let productUpdateSnapshot = null; let productUpdatePollTimer = null; - 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"; @@ -423,25 +439,7 @@ import { 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) { @@ -461,38 +459,6 @@ import { } } - 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(); @@ -765,13 +731,12 @@ import { 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() { @@ -843,11 +808,6 @@ import { 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()) { @@ -1629,28 +1589,6 @@ import { 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/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/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 || ""); +} From baba2dd0e86b1f5aadafda34f0675e6daaaf08f2 Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 18:15:49 +0800 Subject: [PATCH 8/9] perf: retire energy-wave canvas effect (high CPU, redundant) The animated bezier+blur canvas ran 6 blur+shadow paint calls at 45fps, causing visible lag on RDP and low-GPU machines. The sideboard already ships three pure-CSS background themes (zero runtime cost), making the canvas effect redundant. createEnergyWave() is now a no-op stub so callers don't break. --- .../wwwroot/modules/energy-wave.js | 251 +----------------- 1 file changed, 5 insertions(+), 246 deletions(-) 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() {} From 6bd23822d5278a9f741832e216c0d1d6eb600f3c Mon Sep 17 00:00:00 2001 From: mabyes1pg Date: Wed, 22 Jul 2026 18:21:07 +0800 Subject: [PATCH 9/9] fix: prevent black frame on initial connection when DXGI has no new frame When DXGI Desktop Duplication returns success but hasNewFrame=false (desktop static), the bitmap buffer stays all-black if it was freshly allocated. This caused the 'connected but black screen' issue on phones. Fix: fall through to GDI CopyFromScreen when DXGI succeeds with no new frame AND the bitmap has never been populated. Also reset the flag when bitmap is reallocated (resolution change). 74/74 tests pass. --- src/PhoneMonitor.Host/Windows/DisplayFrameSource.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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;