diff --git a/docs/superpowers/specs/2026-07-05-mobile-scaling-and-pwa-design.md b/docs/superpowers/specs/2026-07-05-mobile-scaling-and-pwa-design.md new file mode 100644 index 0000000..63d0b36 --- /dev/null +++ b/docs/superpowers/specs/2026-07-05-mobile-scaling-and-pwa-design.md @@ -0,0 +1,75 @@ +# Mobile scaling fixes + PWA support — design + +Date: 2026-07-05. Scope: `src/web_ui/` + `src/web.rs` + `tests/api.rs`. + +## Problem + +1. **Mobile scaling regressions.** The "private cinematheque" design layer was + appended *after* the `@media(max-width:640px)` / `@media(max-width:380px)` + blocks in `src/web_ui/index.html`. Media queries add no specificity, so the + design layer's base rules (header padding, `.vid-wrap video` max-height, …) + override the mobile rules by source order. The design layer also introduced + whole components with **no** mobile rules at all: the custom player + (`.pl-*`), command palette (`.cp-*`), stats dashboard (`.sx-*`) and + maintenance console (`.mx-*`). +2. The viewport meta lacks `viewport-fit=cover`, so every + `env(safe-area-inset-*)` the CSS relies on evaluates to 0 on iPhones. +3. Text inputs are 13px, which triggers iOS Safari's automatic focus-zoom — + the classic "the page scales itself when I tap search" bug. +4. No PWA support: no manifest, no service worker, no installability, and the + bundled `icon.png` is a YouTube-logo lookalike unsuitable for an installed + app icon. + +## Approach chosen + +**Mobile fixes (index.html only, no JS-visible id/class changes):** + +- Add `viewport-fit=cover` to the viewport meta. +- Move both mobile media-query blocks to the **end** of the stylesheet, after + the design layer, with a comment stating the ordering invariant. This is the + structural fix; alternatives (raising specificity, `!important`) were + rejected as fragile. +- Inside the ≤640px block: bump text inputs/selects to 16px (kills iOS + focus-zoom), and add rules for the new components — full-bleed command + palette with `dvh` sizing, clamped stats-hero numerals, safe-area padding. +- New `@media(pointer:coarse)` block for touch ergonomics independent of + width: taller scrub track, always-visible seek thumb, ≥40px player buttons, + hide the hover-oriented volume slider (phones use hardware volume). + +**PWA (additive, all static assets embedded like the existing HTML):** + +- `src/web_ui/manifest.webmanifest` — name/short_name Catacomb, `display: + standalone`, `start_url: /`, `id: /`, theme/background `#1a1a2e`, icons + 192/512 + maskable 512. +- New Catacomb-branded icon: `src/web_ui/icon.svg` (crimson catacomb arch on + the app's dark palette) rendered to `icon-192.png`, `icon-512.png`, + `icon-maskable-512.png`, `apple-touch-icon.png` (180px, opaque). SVG source + checked in; PNGs regenerated via `rsvg-convert`. +- `src/web_ui/sw.js` — deliberately minimal service worker. Intercepts **only** + GET navigations to `/` (network-first, falling back to a cached copy when + offline) and the static icon/manifest paths (cache-first). It never touches + `/api/*`, `/ws/*`, `/files/*`, `/music-files/*`, or `/feed*` — no interference + with auth, streaming ranges, or WebSockets. Served `no-store` so upgrades + propagate on next load, matching the existing "no stale UI" policy. +- `src/web.rs` — `include_str!`/`include_bytes!` consts + routes for + `/manifest.webmanifest`, `/sw.js`, `/icons/*`, `/apple-touch-icon.png`; + these paths are allowlisted (GET, static, nothing sensitive) in + `auth_middleware` so the browser can fetch them pre-login. +- `index.html` head: manifest link, `theme-color` meta (kept in sync with the + active theme's `--panel` by `applyTheme`), apple-touch-icon link, iOS + standalone metas; SW registration guarded by `'serviceWorker' in navigator`. + +## Testing + +- `tests/api.rs`: new test asserting `/manifest.webmanifest`, `/sw.js`, and an + icon route return 200 with sane content types, and that they are reachable + without auth when a password is set (follows the existing curl harness). +- `node --check` on the extracted inline script and on `sw.js`. +- Headless Chromium screenshots at phone viewports (375×812, 360×740) before + and after, against a real `--web` instance. + +## Out of scope + +Offline caching of library data/media, push notifications, background sync, +replacing `icon.png` used by the desktop `.desktop` entry (flagged for a +follow-up since it's a YouTube trademark lookalike). diff --git a/src/web.rs b/src/web.rs index 68ab3ed..d674a85 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1160,7 +1160,7 @@ async fn security_headers(req: Request, next: Next) -> Response { img-src 'self' data: blob: https:; \ media-src 'self' blob:; \ connect-src 'self'; \ - font-src 'self'; \ + font-src 'self' data:; \ object-src 'none'; \ base-uri 'self'; \ frame-ancestors 'none'"; @@ -1187,6 +1187,19 @@ async fn auth_middleware( if path == "/api/login" { return next.run(req).await; } + // PWA static assets. Browsers fetch the manifest/icons during install + // (not always with credentials) and must reach the service worker script + // to register it from the login page's origin. All of these are + // compile-time constants with nothing user- or library-specific, so + // serving them pre-auth leaks nothing. + if req.method().as_str() == "GET" + && (path == "/manifest.webmanifest" + || path == "/sw.js" + || path == "/apple-touch-icon.png" + || path.starts_with("/icons/")) + { + return next.run(req).await; + } if is_authed(&state, req.headers()) { return next.run(req).await; } @@ -3303,6 +3316,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { let app = Router::new() .route("/", get(get_index)) + .route("/manifest.webmanifest", get(get_manifest)) + .route("/sw.js", get(get_sw)) + .route("/icons/:name", get(get_icon)) + .route("/apple-touch-icon.png", get(get_apple_touch_icon)) .route("/api/library", get(get_library)) .route("/api/progress", get(get_progress)) .route("/ws/progress", get(ws_progress)) @@ -3439,3 +3456,60 @@ const LOGIN_HTML: &str = include_str!("web_ui/login.html"); /// Main library UI. Single-page app with embedded styles and JS — see the /// architecture notes at the top of `web_ui/index.html`. const HTML_UI: &str = include_str!("web_ui/index.html"); + +/// PWA assets, embedded like the HTML. The icon PNGs are rendered from +/// `web_ui/icon.svg` — see the comment there for the regen commands. +const MANIFEST: &str = include_str!("web_ui/manifest.webmanifest"); +const SW_JS: &str = include_str!("web_ui/sw.js"); +const ICON_192: &[u8] = include_bytes!("web_ui/icon-192.png"); +const ICON_512: &[u8] = include_bytes!("web_ui/icon-512.png"); +const APPLE_TOUCH_ICON: &[u8] = include_bytes!("web_ui/apple-touch-icon.png"); + +async fn get_manifest() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "application/manifest+json"), + (header::CACHE_CONTROL, "public, max-age=3600"), + ], + MANIFEST, + ) +} + +async fn get_sw() -> impl IntoResponse { + // no-store, same reasoning as the HTML: binary upgrades change the + // embedded worker without changing the URL, and a stale SW would pin + // stale behavior across upgrades. + ( + [ + (header::CONTENT_TYPE, "text/javascript; charset=utf-8"), + (header::CACHE_CONTROL, "no-store"), + ], + SW_JS, + ) +} + +async fn get_icon(Path(name): Path) -> Response { + let bytes: &'static [u8] = match name.as_str() { + "icon-192.png" => ICON_192, + "icon-512.png" => ICON_512, + _ => return StatusCode::NOT_FOUND.into_response(), + }; + ( + [ + (header::CONTENT_TYPE, "image/png"), + (header::CACHE_CONTROL, "public, max-age=86400"), + ], + bytes, + ) + .into_response() +} + +async fn get_apple_touch_icon() -> impl IntoResponse { + ( + [ + (header::CONTENT_TYPE, "image/png"), + (header::CACHE_CONTROL, "public, max-age=86400"), + ], + APPLE_TOUCH_ICON, + ) +} diff --git a/src/web_ui/apple-touch-icon.png b/src/web_ui/apple-touch-icon.png new file mode 100644 index 0000000..e0292f9 Binary files /dev/null and b/src/web_ui/apple-touch-icon.png differ diff --git a/src/web_ui/icon-192.png b/src/web_ui/icon-192.png new file mode 100644 index 0000000..f375b0b Binary files /dev/null and b/src/web_ui/icon-192.png differ diff --git a/src/web_ui/icon-512.png b/src/web_ui/icon-512.png new file mode 100644 index 0000000..2afebd9 Binary files /dev/null and b/src/web_ui/icon-512.png differ diff --git a/src/web_ui/icon.svg b/src/web_ui/icon.svg new file mode 100644 index 0000000..96345cf --- /dev/null +++ b/src/web_ui/icon.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/web_ui/index.html b/src/web_ui/index.html index cc9da0e..01e6da0 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -1,8 +1,16 @@ - + Catacomb + + + + + + + + @@ -167,71 +175,9 @@ .dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap} .preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0} .empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px;font-size:13px} - @media(max-width:640px){ - body{padding-bottom:calc(env(safe-area-inset-bottom) + 4px)} - #menu-btn{display:block} - aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px} - aside.open{transform:translateX(0)} - #sidebar-overlay.open{display:block} - /* Hide non-essential header chrome on mobile. The remaining - buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings — - are the ones users actually reach for. */ - #hdr-stats,#sort,#agpl-notice{display:none} - header h1{display:none} - header{padding:6px 8px;gap:6px;position:sticky;top:0;z-index:20} - #search{order:3;flex-basis:100%;width:100%;min-width:0} - header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px} - .filters{overflow-x:auto;padding-bottom:2px;scrollbar-width:none} - .filters::-webkit-scrollbar{display:none} - .toolbar{gap:6px} - .toolbar > *{min-width:0} - /* Bigger tap targets on cards. The five-button card-foot reflows - to a comfortable height instead of cramming icons together. */ - .grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px} - .card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap} - .card-foot button{font-size:13px;padding:6px 8px;min-height:34px} - .card-foot button:not(.play){min-width:34px} - .card-title{min-height:0} - .card-meta{font-size:10px;line-height:1.3} - /* Modals go full-screen so the cramped 10px page-margin we use on - desktop doesn't eat the iPhone safe area. */ - .modal-bg{padding:0} - .modal{max-width:100vw;max-height:100dvh;height:100dvh;border-radius:0;border:none;overflow-y:auto;padding:12px 12px calc(12px + env(safe-area-inset-bottom))} - .modal-body{flex-direction:column;gap:8px;overflow-y:auto} - .modal video{max-height:55vh} - .vctrl{padding:6px;gap:6px} - .vctrl .vseek{order:3;flex-basis:100%;min-width:0} - .vctrl .vtime{margin-left:auto} - .settings-row{flex-direction:column;align-items:flex-start;gap:4px} - .meta-grid{grid-template-columns:1fr} - .det-head{flex-direction:column;gap:4px} - .det-actions{width:100%} - .det-actions button{flex:1 1 calc(50% - 4px)} - #details{max-height:min(45vh,220px);padding:8px 10px} - .chapters-pane{display:none} - .transcript-pane{display:none !important} - section#content{padding:8px} - /* Modal "new download" form stacks vertically on small screens so - the URL + button + quality picker don't squeeze each other. */ - .dl-new-row{flex-direction:column;align-items:stretch} - .dl-new-row input[type="url"]{width:100%;min-width:0} - .dl-new-row select{width:100%} - /* Bulk-mode actions stack vertically when active. */ - .toolbar{flex-wrap:wrap} - #bulk-actions{flex-wrap:wrap;gap:4px} - #bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px} - /* Job rows: hide the last-line preview on small screens; the - progress bar + label are the only things that fit anyway. */ - .job{padding:4px 10px;font-size:11px} - .job .last-line{display:none} - } - @media(max-width:380px){ - /* Single-column on very narrow phones. */ - .grid{grid-template-columns:1fr;gap:6px} - .card-foot{flex-direction:column;align-items:stretch} - .card-foot button{width:100%;min-width:0} - .det-actions button{flex-basis:100%} - } + /* Mobile layout rules live at the BOTTOM of this stylesheet (after the + design layer) — media queries add no specificity, so they must come + last in source order to override the design layer's base rules. */ /* ─────────────────────────────────────────────────────────────────────── Design layer — "private cinematheque" reskin. Appended on purpose so it @@ -543,6 +489,121 @@ .cp-action-btn{background:color-mix(in srgb,var(--card) 55%,transparent);border:1px solid var(--hair);color:var(--text);padding:5px 10px;border-radius:var(--radius-sm);cursor:pointer;font-size:11px;transition:background .12s,border-color .12s} .cp-action-btn:hover{background:color-mix(in srgb,var(--accent) 15%,transparent);border-color:var(--accent);color:var(--accent)} + /* ─────────────────────────────────────────────────────────────────────── + Mobile layout. MUST stay at the bottom of the stylesheet: media queries + add no specificity, so these only beat the design-layer rules above by + source order. (They used to sit before the design layer, which silently + re-broke header padding, video heights, etc. on phones.) + ─────────────────────────────────────────────────────────────────────── */ + @media(max-width:640px){ + body{padding-bottom:calc(env(safe-area-inset-bottom) + 4px)} + #menu-btn{display:block} + /* animation:none is load-bearing: the design layer's uiDrop entry + animation has fill-mode:both, and animation fill overrides the static + transform below — leaving the drawer stuck open over the content. */ + aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px;padding-left:env(safe-area-inset-left);animation:none} + aside.open{transform:translateX(0)} + #sidebar-overlay.open{display:block} + /* Hide non-essential header chrome on mobile. The remaining + buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings — + are the ones users actually reach for. */ + #hdr-stats,#sort,#agpl-notice{display:none} + header h1{display:none} + header{padding:6px max(8px,env(safe-area-inset-right)) 6px max(8px,env(safe-area-inset-left));gap:6px;position:sticky;top:0;z-index:20} + #search{order:3;flex-basis:100%;width:100%;min-width:0} + header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px} + /* 16px inputs: anything smaller makes iOS Safari zoom the whole page + when the field is focused — the classic mobile "scaling bug". */ + #search,#sort,select,textarea,.t-search,.cp-input, + input[type="url"],input[type="text"],input[type="number"],input[type="search"],input[type="password"]{font-size:16px} + .filters{overflow-x:auto;padding-bottom:2px;scrollbar-width:none} + .filters::-webkit-scrollbar{display:none} + .toolbar{gap:6px} + .toolbar > *{min-width:0} + /* Bigger tap targets on cards. The five-button card-foot reflows + to a comfortable height instead of cramming icons together. */ + .grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px} + .card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap} + .card-foot button{font-size:13px;padding:6px 8px;min-height:34px} + .card-foot button:not(.play){min-width:34px} + .card-title{min-height:0} + .card-meta{font-size:10px;line-height:1.3} + /* Modals go full-screen so the cramped 10px page-margin we use on + desktop doesn't eat the iPhone safe area. */ + .modal-bg{padding:0} + .modal{max-width:100vw;max-height:100dvh;height:100dvh;border-radius:0;border:none;overflow-y:auto; + padding:12px calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))} + .modal-body{flex-direction:column;gap:8px;overflow-y:auto} + .modal video{max-height:55vh} + .modal-hdr h2,.modal h2{font-size:1.2rem} + .vid-wrap{flex:0 0 auto} + .vctrl{padding:6px;gap:6px} + .vctrl .vseek{order:3;flex-basis:100%;min-width:0} + .vctrl .vtime{margin-left:auto} + .settings-row{flex-direction:column;align-items:flex-start;gap:4px} + .meta-grid{grid-template-columns:1fr} + .det-head{flex-direction:column;gap:4px} + .det-actions{width:100%} + .det-actions button{flex:1 1 calc(50% - 4px)} + #details{max-height:min(45vh,220px);padding:8px 10px} + .chapters-pane{display:none} + .transcript-pane{display:none !important} + section#content{padding:8px max(8px,env(safe-area-inset-right)) 8px max(8px,env(safe-area-inset-left))} + /* Modal "new download" form stacks vertically on small screens so + the URL + button + quality picker don't squeeze each other. */ + .dl-new-row{flex-direction:column;align-items:stretch} + .dl-new-row input[type="url"]{width:100%;min-width:0} + .dl-new-row select{width:100%} + /* Bulk-mode actions stack vertically when active. */ + .toolbar{flex-wrap:wrap} + #bulk-actions{flex-wrap:wrap;gap:4px} + #bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px} + /* Job rows: hide the last-line preview on small screens; the + progress bar + label are the only things that fit anyway. */ + .job{padding:4px 10px;font-size:11px} + .job .last-line{display:none} + /* Command palette becomes a full-height sheet instead of a floating + card 60px down a 640px column. */ + .cp-container{max-width:none;margin:0;height:100dvh;border-radius:0;border:none; + padding-top:env(safe-area-inset-top)} + .cp-results{flex:1 1 auto;max-height:none} + .cp-input-wrap{padding:12px 14px} + .cp-actions{padding-bottom:calc(10px + env(safe-area-inset-bottom))} + /* Stats dashboard: the display-serif numerals are sized for desktop + and overflow 150px metric cards on phones. */ + .sx-hero{grid-column:1/-1} + .sx-hero .sx-v{font-size:2.6rem} + .sx-card .sx-v{font-size:1.7rem} + .sx-cols{gap:4px} + .mx-verdict{font-size:1.6rem} + .mx-hero{padding:12px 14px;gap:12px} + /* Custom player chrome: phones have hardware volume buttons and no + hover — drop the slider, keep the rest. */ + .pl-vol{display:none} + .pl-time{font-size:12px;margin:0 3px} + } + @media(max-width:350px){ + /* Single-column on genuinely narrow phones (~320px class). Anything + wider — 360px Androids, 375pt iPhones — fits two 150px+ columns; + a 380px breakpoint used to catch ALL of those and blow the cards + up to comical full-width. */ + .grid{grid-template-columns:1fr;gap:6px} + .card-foot{flex-direction:column;align-items:stretch} + .card-foot button{width:100%;min-width:0} + .det-actions button{flex-basis:100%} + .sx-metrics{grid-template-columns:1fr 1fr} + } + @media(pointer:coarse){ + /* Touch ergonomics regardless of viewport width (tablets too). The + hover-revealed seek thumb is always shown and the scrub strip grows + to a comfortable finger target. */ + .pl-controls button{font-size:18px;padding:8px 9px} + .pl-track{height:30px} + .pl-hover{bottom:26px} + .pl-thumb{transform:translateY(-50%) scale(1)} + .vctrl .vbtn{font-size:18px;padding:8px 10px} + .chip,.chip-toggle,.chip-clear{padding:6px 10px} + } @media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}} @@ -2242,7 +2303,11 @@ function switchMetaTab(tab,which){tab.parentElement.querySelectorAll('.meta-tab' /* ── Settings ───────────────────────────────────────────────────── */ const THEMES=[['dark','Dark'],['light','Light'],['dracula','Dracula'],['trans','Trans'],['emo-nocturnal','Emo: Nocturnal'],['emo-coffin','Emo: Coffin'],['emo-scene-queen','Emo: Scene Queen'],['solarized','Solarized'],['nord','Nord'],['amoled','AMOLED']]; -function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t)} +function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t); + // Keep the browser/OS chrome (Android status bar, installed-PWA title bar) + // in step with the active theme's panel colour. + const tc=document.querySelector('meta[name=theme-color]'); + if(tc)tc.content=getComputedStyle(document.body).getPropertyValue('--panel').trim()||'#16213e';} async function logout(){try{await fetch('/api/logout',{method:'POST'});location.reload()}catch{}} @@ -3490,6 +3555,10 @@ loadFilterPresets(); })(); loadRemotes(); loadSourceNotice(); +// PWA: register the (deliberately minimal) service worker — see sw.js for +// exactly what it does and does not intercept. Requires a secure context +// (https or localhost); elsewhere navigator.serviceWorker is undefined. +if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js').catch(()=>{}); diff --git a/src/web_ui/login.html b/src/web_ui/login.html index ebce37a..d92be72 100644 --- a/src/web_ui/login.html +++ b/src/web_ui/login.html @@ -1,8 +1,12 @@ - + Catacomb — Sign in + + + +