feat(web): fix mobile scaling regressions and ship installable PWA

Mobile fixes (all in the embedded SPA):
- Move the mobile media queries to the bottom of the stylesheet — the
  cinematheque design layer was appended after them, so its base rules
  (header padding, video heights) silently overrode the phone layout by
  source order. A comment now pins the ordering invariant.
- The design layer's uiDrop entry animation (fill-mode:both) permanently
  overrode the sidebar's translateX(-100%), leaving the drawer stuck open
  over the content on phones; the mobile block now disables it.
- Lower the single-column breakpoint 380px -> 350px: it was catching
  360/375px phones and blowing every card up to full width.
- viewport-fit=cover so the env(safe-area-inset-*) paddings actually
  resolve on iPhones; notch-safe padding on header/content/modals.
- 16px inputs on small screens (kills iOS Safari's focus auto-zoom),
  same for the login page's password field.
- Mobile rules for the design-layer components that had none: full-height
  command-palette sheet, clamped stats numerals, coarse-pointer touch
  targets for the custom player (taller scrub track, always-visible
  thumb, no volume slider).
- CSP font-src now allows data: — the embedded base64 woff2 fonts were
  being blocked and silently fell back to system faces.

PWA:
- manifest.webmanifest, minimal service worker, and a new Catacomb arch
  icon set (SVG source + rendered PNGs incl. maskable + apple-touch),
  all include_str!/include_bytes!-embedded like the HTML.
- sw.js only intercepts GET navigations to "/" (network-first, cached
  offline fallback) and the static assets; /api, /ws, /files,
  /music-files, /feed are never touched, and it's served no-store so
  binary upgrades keep propagating.
- Routes + auth_middleware allowlist so the browser can fetch the
  statics pre-login; theme-color meta tracks the active theme's panel.
- tests/api.rs covers the new endpoints incl. the ungated-when-password
  invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-08 00:04:17 -07:00
parent 52c561b47b
commit 891b1e0d3c
No known key found for this signature in database
11 changed files with 435 additions and 70 deletions

View file

@ -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<String>) -> 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,
)
}