diff --git a/internal/ui/live_page.go b/internal/ui/live_page.go index 71c354e..b9fa218 100644 --- a/internal/ui/live_page.go +++ b/internal/ui/live_page.go @@ -13,6 +13,45 @@ type liveDocumentData struct { BodyAttrs template.HTMLAttr } +// wcoBootScript toggles a `wco` class on when the PWA is running with +// Window Controls Overlay so the app can paint its own header into the OS title +// bar. Runs in (before exists) so the class is set on the root +// element with no flash, and tracks runtime changes via geometrychange. +// wcoBootScript runs in before any CSS loads. +// It does two things: +// 1. Sets an inline background-color on from localStorage so the +// correct theme colour is present from the very first paint, eliminating +// the white/gray flash visible in the WCO title-bar area during navigation. +// 2. Toggles the `wco` class when Window Controls Overlay is active. +// wcoBootScript runs in before any CSS loads. +// It does two things: +// 1. Sets an inline background-color on matching the current theme +// and WCO state so the correct colour is present from the very first +// paint, eliminating the white/gray flash in the title-bar area. +// 2. Toggles the `wco` class when Window Controls Overlay is active. +const wcoBootScript = `` + func renderLiveDocumentStart(data liveDocumentData) string { var b strings.Builder b.WriteString("\n\n\n") @@ -32,6 +71,8 @@ func renderLiveDocumentStart(data liveDocumentData) string { b.WriteString("\n") b.WriteString("\n") b.WriteString("\n") + b.WriteString(wcoBootScript) + b.WriteByte('\n') if data.Styles != "" { b.WriteString(string(data.Styles)) b.WriteByte('\n') @@ -76,14 +117,15 @@ func themeBootScript(defaultTheme string) template.HTML { else if(t === 'custom') icon = '⚙'; document.querySelectorAll('[data-theme-icon]').forEach(function(el){ el.textContent = icon; }); document.querySelectorAll('[data-command-theme-icon]').forEach(function(el){ el.textContent = icon; }); + var isWCO = navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible; + var chromeBg = '#0f0f14', bodyBg = '#111116'; + if(t === 'light') { chromeBg = '#ddddda'; bodyBg = '#f6f5f2'; } + else if(t === 'nord') { chromeBg = '#292f3a'; bodyBg = '#2e3440'; } + else if(t === 'dracula') { chromeBg = '#242631'; bodyBg = '#282a36'; } + var color = isWCO ? chromeBg : bodyBg; + document.documentElement.style.backgroundColor = color; var meta = document.querySelector('meta[name="theme-color"]'); - if(meta) { - var color = '#111116'; - if(t === 'light') color = '#f6f5f2'; - else if(t === 'nord') color = '#2e3440'; - else if(t === 'dracula') color = '#282a36'; - meta.content = color; - } + if(meta) { meta.content = color; } } function toggleTheme(){ var idx = themes.indexOf(currentTheme()); diff --git a/internal/ui/live_templates/assets/manifest.webmanifest b/internal/ui/live_templates/assets/manifest.webmanifest index dbcc276..14b032b 100644 --- a/internal/ui/live_templates/assets/manifest.webmanifest +++ b/internal/ui/live_templates/assets/manifest.webmanifest @@ -5,6 +5,7 @@ "start_url": "/", "scope": "/", "display": "standalone", + "display_override": ["window-controls-overlay", "standalone"], "background_color": "#0e0e13", "theme_color": "#0e0e13", "icons": [ diff --git a/internal/ui/live_templates/session.html b/internal/ui/live_templates/session.html index 4932e16..7e10840 100644 --- a/internal/ui/live_templates/session.html +++ b/internal/ui/live_templates/session.html @@ -1,4 +1,4 @@ -{{.LiveDocumentStart}} +{{.LiveDocumentStart}} {{.ThemeBoot}} {{.ServiceWorker}} diff --git a/internal/ui/live_templates/styles/index.css b/internal/ui/live_templates/styles/index.css index b324203..f5f657f 100644 --- a/internal/ui/live_templates/styles/index.css +++ b/internal/ui/live_templates/styles/index.css @@ -44,6 +44,34 @@ a { color: inherit; } margin: 0 auto; padding: 16px 28px 18px; } +/* Standalone without WCO: merge header with OS title bar (both use body-bg). */ +@media (display-mode: standalone) { + .header { + background: var(--body-bg); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } +} + +/* Window Controls Overlay (installed PWA): make the header act as the OS title + bar — draggable, theme-matched, clearing the window-control inset. */ +:root.wco .header { + background: var(--chrome-bg); + backdrop-filter: none; + -webkit-backdrop-filter: none; + -webkit-app-region: drag; + app-region: drag; +} +:root.wco .header-inner { + padding-left: calc(env(titlebar-area-x, 0px) + 28px); +} +:root.wco .header a, +:root.wco .header button, +:root.wco .header input, +:root.wco .header kbd { + -webkit-app-region: no-drag; + app-region: no-drag; +} .header-top { display: flex; align-items: center; diff --git a/internal/ui/live_templates/styles/session.css b/internal/ui/live_templates/styles/session.css index 3bf0b2e..262483f 100644 --- a/internal/ui/live_templates/styles/session.css +++ b/internal/ui/live_templates/styles/session.css @@ -843,6 +843,11 @@ display: flex; flex-direction: column; overflow: hidden; + /* Lift the lit reading column above the darker chrome so it reads as a + raised panel, especially when framed by both sidebars. */ + position: relative; + z-index: 1; + box-shadow: 0 0 22px rgba(0, 0, 0, 0.13); } /* Main content — scrolls independently within #app's fixed height */ @@ -1050,6 +1055,41 @@ -webkit-backdrop-filter: blur(16px); } + /* Standalone PWA without WCO: the OS draws its own title bar above the + page. Make the session header use --body-bg (same as theme-color in + this mode) so the OS strip and our header merge into one seamless zone, + clearly distinct from the WCO compact look. */ + @media (display-mode: standalone) { + .session-header-bar { + background: var(--body-bg); + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + } + + /* Window Controls Overlay (installed PWA): draw the header into the OS + title bar. Reserve the inset for the window controls and make the bar + draggable, while keeping interactive children clickable. */ + :root.wco .session-header-bar { + /* Stay full-width (base left:0/right:0) so the bottom border spans the + whole window; just inset the content past the OS window controls on + both sides. Background matches theme-color (== --chrome-bg) so the + browser-painted control strip blends seamlessly. */ + padding-left: calc(env(titlebar-area-x, 0px) + 18px); + padding-right: calc(100% - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100%) + 18px); + background: var(--chrome-bg); + backdrop-filter: none; + -webkit-backdrop-filter: none; + -webkit-app-region: drag; + app-region: drag; + } + + :root.wco .session-header-bar a, + :root.wco .session-header-bar button { + -webkit-app-region: no-drag; + app-region: no-drag; + } + .session-header-back { display: inline-flex; align-items: center; @@ -2193,22 +2233,29 @@ width: 100%; box-sizing: border-box; display: block; - padding: 18px 14px calc(14px + env(safe-area-inset-bottom)); - background: var(--chrome-bg); + padding: 14px 14px 0; + /* Share the reading column's surface so there's no hard seam between the + message stream and the composer — the shell card below is the only + visible frame. */ + background: var(--body-bg); } - /* Git branch + Create PR row beneath the input box. */ + /* Git branch + Create PR row — a full-bleed footer dock anchored to the + bottom of the reading column. The negative margins cancel the composer's + horizontal padding so the chrome surface runs edge to edge, framing the + column from below the way the sidebars frame it from the sides. */ .pi-git-bar { - width: 100%; - max-width: 760px; - margin: 8px auto 0; + width: auto; + margin: 14px -14px 0; box-sizing: border-box; display: flex; align-items: center; justify-content: space-between; gap: 10px; - padding: 0 2px; + padding: 9px 16px calc(9px + env(safe-area-inset-bottom)); font-size: 11px; + background: var(--chrome-bg); + border-top: 1px solid var(--dim); } .pi-git-bar[hidden] { display: none; } @@ -2364,14 +2411,15 @@ overflow: visible; background: var(--input-bg); border: 1px solid var(--dim); - border-radius: 6px; + border-radius: 10px; position: relative; padding: 3px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); transition: border-color 0.15s, box-shadow 0.2s; } .pi-chat-shell:focus-within { border-color: color-mix(in srgb, var(--accent, #8abeb7) 60%, var(--dim)); - box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.14); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); } .pi-chat-model-popup { diff --git a/internal/ui/live_templates/styles/theme.css b/internal/ui/live_templates/styles/theme.css index 72a09ef..95df894 100644 --- a/internal/ui/live_templates/styles/theme.css +++ b/internal/ui/live_templates/styles/theme.css @@ -1,5 +1,15 @@ /* ── Unified CSS Variables Theme Engine ── */ +/* Smooth cross-fade for MPA navigations (Chrome 126+). Eliminates the + hard-cut flash when moving between the index and session pages. */ +@view-transition { + navigation: auto; +} +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.12s; +} + :root { --font-sans: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace; --palette-surface: var(--surface); diff --git a/internal/ui/session_page.go b/internal/ui/session_page.go index 9ccf6e9..948d551 100644 --- a/internal/ui/session_page.go +++ b/internal/ui/session_page.go @@ -98,7 +98,7 @@ func RenderLiveSessionPage(session sessions.Session) string { Styles: template.HTML(styles), BodyAttrs: template.HTMLAttr(bodyAttrs), })), - ThemeBoot: liveThemeBootScript(), + ThemeBoot: themeBootScript("nord"), ServiceWorker: liveServiceWorkerScript(), SessionCommandMenu: sessionDesktopMenuHTML(), MobileCommandMenu: sessionMobileMenuHTML(), diff --git a/web/package-lock.json b/web/package-lock.json index b501f4c..2160af2 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -271,44 +271,6 @@ } } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -316,19 +278,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -778,21 +727,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -813,15 +747,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -832,15 +757,6 @@ "node": ">=18" } }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1509,18 +1425,6 @@ "dev": true, "license": "ISC" }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1531,19 +1435,6 @@ "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -1565,27 +1456,6 @@ "dev": true, "license": "MIT" }, - "node_modules/terser": { - "version": "5.47.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.47.0.tgz", - "integrity": "sha512-TV+JFkQFtljk12ffyYAA4+zVF4Hs+qaROsT+Qo9o2/z39x+IUn+pvsmomiCPlp5YigfR1OdbGHOvc0L+Ca1X7g==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",