freezmo/public/sw.js
Martin Dvorak 705808d45a Pickers: anchor the desktop dropdown to its chip, never clipped
"The model selection in teach it back is kinda broken at least on desktop":
the desktop panels were position:absolute children of whatever container held
their chip. In chat that is the composer at the bottom of the column, which
works, but Teach it back keeps its chips inside a scrolling form card — so the
panel opened upward out of the card and its top rows (including the selected
model) were cut off. The probe measured top: -66px and no way to reach the
first rows by scrolling the list.

- New components/chat/PickerPopover.tsx: the desktop surface for both pickers,
  portalled to <body> and position:fixed, anchored to the opening chip
  ([data-picker-trigger][aria-expanded="true"]), opening on the side with room
  and capped to the viewport so the panel scrolls as a whole with its heading
  and note attached. It closes on an outside press, Escape, resize or a page
  scroll — but not on scrolling its own list.
- ContextPicker and ModelPicker use it on desktop; the phone sheets and their
  inner list caps are unchanged (the list max-height became a parameter: the
  sheet caps it, the desktop panel scrolls instead).
- teach-test pins it: the panel's rect is fully inside the viewport,
  elementFromPoint over it lands on the panel, it is not a descendant of the
  scrolling card, and picking a model updates the chip + freezmo.chat.model.v1
  (46 checks). Chat and the phone sheets re-verified unchanged (110 + 128).

Version 0.8.3, SW v105.
2026-10-18 21:55:47 +00:00

139 lines
4.2 KiB
JavaScript

/**
* Freezmo service worker (production only).
*
* Strategy, deliberately conservative:
* - API routes are never cached and never intercepted.
* - Navigations: network first, falling back to the cached shell, then /offline.
* - /_next/static + icons: stale-while-revalidate (fast, self-healing).
* - Everything else: straight to the network.
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
const CACHE_VERSION = "freezmo-v105";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
const SHELL_ASSETS = [
"/",
"/offline",
"/manifest.webmanifest",
"/icons/icon-192.png",
"/icons/icon-512.png",
"/icons/apple-touch-icon.png",
];
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
await Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(new Request(url, { cache: "reload" }))));
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(
names.filter((name) => !name.startsWith(CACHE_VERSION)).map((name) => caches.delete(name)),
);
await self.clients.claim();
})(),
);
});
function isCacheableAsset(url) {
return (
url.pathname.startsWith("/_next/static/") ||
url.pathname.startsWith("/icons/") ||
url.pathname === "/manifest.webmanifest" ||
/\.(?:css|js|png|jpg|jpeg|svg|webp|woff2?)$/.test(url.pathname)
);
}
/* ------------------------------------------------------------------ */
/* Push reminders (opt-in; only active when VAPID is configured) */
/* ------------------------------------------------------------------ */
self.addEventListener("push", (event) => {
let data = {};
try {
data = event.data ? event.data.json() : {};
} catch {
data = {};
}
event.waitUntil(
self.registration.showNotification(data.title || "Freezmo", {
body: data.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
data: { url: data.url || "/" },
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = (event.notification.data && event.notification.data.url) || "/";
event.waitUntil(
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if ("focus" in client) {
client.navigate(url).catch(() => {});
return client.focus();
}
}
return self.clients.openWindow(url);
}),
);
});
self.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
// Never touch the API: keys, notes and generated decks must always be live.
if (url.pathname.startsWith("/api/")) return;
if (request.mode === "navigate") {
event.respondWith(
(async () => {
try {
const response = await fetch(request);
// Only a real page becomes the offline shell: caching a 404 (a typo
// in the URL, a dead link) would serve the error page to every
// later offline navigation.
if (response.ok) {
const cache = await caches.open(SHELL_CACHE);
cache.put("/", response.clone()).catch(() => {});
}
return response;
} catch {
const cache = await caches.open(SHELL_CACHE);
return (await cache.match("/")) ?? (await cache.match("/offline")) ?? Response.error();
}
})(),
);
return;
}
if (isCacheableAsset(url)) {
event.respondWith(
(async () => {
const cache = await caches.open(ASSET_CACHE);
const cached = await cache.match(request);
const network = fetch(request)
.then((response) => {
if (response.ok) cache.put(request, response.clone()).catch(() => {});
return response;
})
.catch(() => cached ?? Response.error());
return cached ?? network;
})(),
);
}
});