From f4d21e2b840ba0e6966e6223d3cd04e5ce60f689 Mon Sep 17 00:00:00 2001 From: Tonio Date: Mon, 19 Oct 2026 15:12:55 -0700 Subject: [PATCH] fix(ui): style the offline recovery screen (#16904) Style the offline recovery page with the supplied character, embedded token-based CSS, and accessible reload action. Include the production-build fixture regression fix. Co-Authored-By: Paperclip --- DESIGN.md | 8 ++ ui/public/sw.js | 57 ++++++++++++- ui/src/index.css | 105 ++++++++++++++++++++++++ ui/src/lib/sw-offline-fallback.test.ts | 10 ++- ui/src/lib/vite-component-names.test.ts | 13 ++- ui/src/lib/vite-sw-build-id.test.ts | 19 +++++ ui/src/lib/vite-sw-build-id.ts | 19 ++++- 7 files changed, 225 insertions(+), 6 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 0a1e5b5adf6..376e563dc8d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -168,3 +168,11 @@ tokenize motion. Principles — reasoning only; values live in `ui/src/index.css in addition to each animation's own component-level guard. Agent Chat and regular task chats keep pending questions as compact “Unanswered question” entries at their original position in history. Dismissing the form or sending a newer user message clears it from the composer without resolving it. Questions never contribute to composer pending counts or navigation. Opening the history entry restores the original form and its draft; submitting later uses the same durable question response path. Actual permission reviews retain their permission checks. + +## Offline recovery + +The service worker recovery page uses a centered character, a dark surface, and +a visible Reload page action. Keep the character inline and embed the marked +`offline-recovery` section from `ui/src/index.css` at build time so the page needs +no network requests or cached assets. Preserve the supplied character palette +through the offline tokens. diff --git a/ui/public/sw.js b/ui/public/sw.js index 7f443c6a001..da8437c2f8c 100644 --- a/ui/public/sw.js +++ b/ui/public/sw.js @@ -5,6 +5,7 @@ // reloads parked tabs onto the fresh bundle. Left as the literal placeholder in // dev, where HMR (not the worker) drives refreshes. const BUILD_ID = "__PAPERCLIP_BUILD_ID__"; +const OFFLINE_STYLES = "__PAPERCLIP_OFFLINE_STYLES__"; // Separate this allowlisted cache from older workers that cached arbitrary URLs. const CACHE_NAME = `paperclip-public-assets-${BUILD_ID}`; const privateRequests = new Set(); @@ -15,9 +16,61 @@ function offlineNavigationResponse() { return new Response(` -Paperclip is offline -

Paperclip is offline

+Paperclip is offline + +
+
+
+ +

Paperclip is offline

+

Check your connection, then reload this page to try again.

+
`, { status: 503, diff --git a/ui/src/index.css b/ui/src/index.css index 4ea5bca233c..b5b9c816f79 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -2,6 +2,111 @@ @import "tailwindcss"; @plugin "@tailwindcss/typography"; +/* offline-recovery:start */ +.paperclip-offline { + --offline-background: #171716; + --offline-foreground: #fafafa; + --offline-muted: #a3a3a3; + --offline-control: #262626; + --offline-control-hover: #333333; + --offline-border: #404040; + --offline-focus: #6995ff; + --offline-font: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --offline-space: 1.5rem; + --offline-identity-gap: calc(var(--offline-space) * 0.8); + --offline-message-gap: calc(var(--offline-space) * 0.75); + --offline-copy-gap: 0.75rem; + --offline-title-size: 1.75rem; + --offline-body-size: 0.9375rem; + --offline-character-size: 9.6rem; + --offline-character-outer-start: #252525; + --offline-character-outer-end: #686c6e; + --offline-character-inner-start: #292929; + --offline-character-inner-end: #727679; + --offline-character-face: #333333; + --offline-character-tear: #f6fdff; + --offline-content-width: 32rem; + --offline-control-height: 2.75rem; + --offline-radius: 0.5rem; + --offline-stroke: 1px; + --offline-focus-width: 2px; + --offline-focus-offset: 4px; + --offline-weight: 600; + --offline-leading: 1.5; + --offline-heading-leading: 1.25; + --offline-tracking: -0.025em; + color-scheme: dark; + box-sizing: border-box; + margin: 0; + min-height: 100vh; + min-height: 100svh; + display: grid; + place-items: center; + padding: var(--offline-space); + background: var(--offline-background); + color: var(--offline-foreground); + font-family: var(--offline-font); + font-size: var(--offline-body-size); + line-height: var(--offline-leading); + text-align: center; +} +.paperclip-offline main { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--offline-space); + width: 100%; + max-width: var(--offline-content-width); +} +.paperclip-offline-message, +.paperclip-offline-identity { + display: flex; + flex-direction: column; + align-items: center; +} +.paperclip-offline-message { + gap: var(--offline-message-gap); +} +.paperclip-offline-identity { + gap: var(--offline-identity-gap); +} +.paperclip-offline-character { + width: var(--offline-character-size); + height: var(--offline-character-size); +} +.paperclip-offline h1 { + margin: 0; + font-size: var(--offline-title-size); + font-weight: var(--offline-weight); + line-height: var(--offline-heading-leading); + letter-spacing: var(--offline-tracking); + text-wrap: balance; +} +.paperclip-offline p { + margin: 0; + color: var(--offline-muted); + text-wrap: pretty; +} +.paperclip-offline button { + min-height: var(--offline-control-height); + padding: var(--offline-copy-gap) var(--offline-space); + border: var(--offline-stroke) solid var(--offline-border); + border-radius: var(--offline-radius); + background: var(--offline-control); + color: inherit; + font: inherit; + font-weight: var(--offline-weight); + cursor: pointer; +} +.paperclip-offline button:hover { + background: var(--offline-control-hover); +} +.paperclip-offline button:focus-visible { + outline: var(--offline-focus-width) solid var(--offline-focus); + outline-offset: var(--offline-focus-offset); +} +/* offline-recovery:end */ + @font-face { font-family: "InterVariable"; src: url("/fonts/InterVariable.woff2") format("woff2"); diff --git a/ui/src/lib/sw-offline-fallback.test.ts b/ui/src/lib/sw-offline-fallback.test.ts index df20eb2b32e..59edcce293b 100644 --- a/ui/src/lib/sw-offline-fallback.test.ts +++ b/ui/src/lib/sw-offline-fallback.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; +import { embedServiceWorkerOfflineStyles } from "./vite-sw-build-id"; const uiRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); @@ -31,7 +32,10 @@ function loadServiceWorkerFetchListener(overrides: { }; // Offline fallback belongs to a stamped production build. Development // leaves Vite requests to the browser instead of intercepting them. - const code = readFileSync(resolve(uiRoot, "public/sw.js"), "utf8") + const code = embedServiceWorkerOfflineStyles( + readFileSync(resolve(uiRoot, "public/sw.js"), "utf8"), + readFileSync(resolve(uiRoot, "src/index.css"), "utf8"), + ) .replace("__PAPERCLIP_BUILD_ID__", "fixture-production"); new Function("self", "caches", "fetch", "Response", "URL", code)( swSelf, @@ -81,6 +85,10 @@ describe("sw.js offline fallback", () => { const body = await response!.text(); expect(body).toContain("Paperclip is offline"); expect(body).toContain("Reload page"); + expect(body).toContain("