From f8b33b43a81dab36100e3c99e4a430c6f62c4c32 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Tue, 2 Jun 2026 12:51:58 +0400 Subject: [PATCH 1/3] fix(deploy): disable HTML/SW caching + memory-only Firestore cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PWA shell updates were sometimes stuck on the previous deploy because both Firebase Hosting (no-cache for HTML/SW) and the SW (precached index.html) served stale content. - firebase/firebase.json: explicit `no-store, no-cache, must-revalidate, max-age=0` for /index.html, /sw.js, /workbox-*.js, /registerSW.js, and /manifest.webmanifest. Hashed assets still get immutable 1y cache. - vite.config.ts: drop `*.html` from precache; override the plugin's default `navigateFallback: "index.html"` (which would emit a broken `createHandlerBoundToURL("index.html")` route that throws at install time once *.html isn't precached); add a NetworkFirst handler for `request.mode === "navigate"` with an explicit `/__/auth/` denylist so the SW doesn't intercept Firebase Auth's redirect handler; skipWaiting + clientsClaim so a fresh deploy lands on the next nav without a manual reload prompt. - src/shared/api/firebase.ts: switch to `initializeFirestore({ localCache: memoryLocalCache() })`. The IDB cache was a foot-gun for a PWA — stale docs survived sign-out on shared machines, and the IDB layer occasionally held a write lock that kept a freshly-deployed shell stuck reading old data. Memory cache is rebuilt each page load — predictable and ACL-safe. --- firebase/firebase.json | 50 +++++++++++++++++++++++++++++++------- src/shared/api/firebase.ts | 15 ++++++++++-- vite.config.ts | 42 +++++++++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 15 deletions(-) diff --git a/firebase/firebase.json b/firebase/firebase.json index 9e6c256..2486f2f 100644 --- a/firebase/firebase.json +++ b/firebase/firebase.json @@ -19,20 +19,35 @@ ], "headers": [ { - "source": "**", + "source": "/index.html", "headers": [ - { "key": "Cache-Control", "value": "no-cache" }, - { "key": "X-Robots-Tag", "value": "noindex, nofollow" }, - { "key": "X-Frame-Options", "value": "DENY" }, - { "key": "X-Content-Type-Options", "value": "nosniff" }, - { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, - { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" } + { "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate, max-age=0" }, + { "key": "Pragma", "value": "no-cache" } ] }, { - "source": "**/*.@(mjs|css|woff|woff2|ttf|otf|jpg|jpeg|gif|png|svg|webp|ico|avif)", + "source": "/sw.js", "headers": [ - { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + { "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate, max-age=0" }, + { "key": "Service-Worker-Allowed", "value": "/" } + ] + }, + { + "source": "/registerSW.js", + "headers": [ + { "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate, max-age=0" } + ] + }, + { + "source": "/workbox-*.js", + "headers": [ + { "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate, max-age=0" } + ] + }, + { + "source": "/manifest.webmanifest", + "headers": [ + { "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate, max-age=0" } ] }, { @@ -40,6 +55,23 @@ "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] + }, + { + "source": "**/*.@(mjs|css|woff|woff2|ttf|otf|jpg|jpeg|gif|png|svg|webp|ico|avif)", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "**", + "headers": [ + { "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate, max-age=0" }, + { "key": "X-Robots-Tag", "value": "noindex, nofollow" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" } + ] } ] } diff --git a/src/shared/api/firebase.ts b/src/shared/api/firebase.ts index 61a6ff9..d3938c0 100644 --- a/src/shared/api/firebase.ts +++ b/src/shared/api/firebase.ts @@ -1,10 +1,17 @@ // Firebase initialization. Web SDK config comes from build-time Vite env; // these values are public identifiers (security lives in firestore.rules and // storage.rules), so it's fine for them to land in the JS bundle. +// +// Firestore uses an explicit memory-only cache. IndexedDB persistence is a +// foot-gun for a PWA: stale docs would survive sign-out and bleed across +// users on a shared machine, and the IDB layer occasionally holds a write +// lock that keeps a freshly-deployed shell stuck reading old data after +// the service worker has already updated. Memory cache is rebuilt on every +// page load — predictable, ACL-safe, and small enough for our workload. import { type FirebaseApp, initializeApp } from "firebase/app"; import { type Auth, getAuth } from "firebase/auth"; -import { type Firestore, getFirestore } from "firebase/firestore"; +import { type Firestore, initializeFirestore, memoryLocalCache } from "firebase/firestore"; import { type FirebaseStorage, getStorage } from "firebase/storage"; function readConfig() { @@ -44,7 +51,11 @@ export function firebaseAuth(): Auth { } export function firestore(): Firestore { - if (!_firestore) _firestore = getFirestore(firebaseApp()); + if (!_firestore) { + _firestore = initializeFirestore(firebaseApp(), { + localCache: memoryLocalCache(), + }); + } return _firestore; } diff --git a/vite.config.ts b/vite.config.ts index 6043ad0..fcc6c97 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,11 +32,45 @@ export default defineConfig({ // The browser's HTTP cache (Cloud Storage already sets immutable // Cache-Control on these objects) gives us fast repeat reads inside // a session without persisting auth-restricted content past it. - globPatterns: ["**/*.{js,css,html,svg,ico,woff,woff2}"], + // + // *.html is deliberately NOT precached: index.html is the entry + // every navigation hits, and precaching it would serve a stale + // shell after a deploy until the next SW activation completes. + // The NetworkFirst handler below always tries the network for HTML + // so a fresh deploy lands on the very next navigation, and falls + // back to its own runtime cache when offline. + globPatterns: ["**/*.{js,css,svg,ico,woff,woff2}"], cleanupOutdatedCaches: true, - navigateFallback: "/index.html", - // Don't intercept Firebase Auth handler URLs. - navigateFallbackDenylist: [/^\/__\/auth\//], + // Take over open tabs immediately on activation. Combined with the + // NetworkFirst HTML handler this means a deploy lands on the next + // navigation without an explicit "update available" prompt. + skipWaiting: true, + clientsClaim: true, + // Explicitly disable workbox's auto-injected NavigationRoute. + // vite-plugin-pwa defaults `navigateFallback` to "index.html", + // which makes workbox-build emit + // registerRoute(new NavigationRoute(createHandlerBoundToURL("index.html"))) + // BEFORE our runtime handler. That route would (a) throw at install + // time because we no longer precache *.html, and (b) match every + // navigation first, preventing the NetworkFirst handler below from + // ever firing. Setting to `undefined` overrides the plugin default. + navigateFallback: undefined, + runtimeCaching: [ + { + // Firebase Auth's signInWithRedirect lands on /__/auth/... + // The SW must not intercept that — the redirect handler + // expects to hit the network. Anything else that navigates + // (a.k.a. clicks a link / types a URL) gets NetworkFirst. + urlPattern: ({ request, url }) => + request.mode === "navigate" && !url.pathname.startsWith("/__/auth/"), + handler: "NetworkFirst", + options: { + cacheName: "app-shell", + networkTimeoutSeconds: 3, + expiration: { maxEntries: 4, maxAgeSeconds: 60 * 60 * 24 * 7 }, + }, + }, + ], }, }), ], From 3fd602c1ae17f92e3c5d36030b7f23d1fb241605 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Tue, 2 Jun 2026 12:52:06 +0400 Subject: [PATCH 2/3] fix(ui): avatar dropdown crashed because GroupLabel was outside any Group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the avatar in the app bar opened a popover that immediately threw `[kobalte]: useMenuGroupContext must be used within a Menu.Group` — DropdownMenuLabel rendered Kobalte's `Menu.GroupLabel` directly inside `DropdownMenuContent` with no surrounding `Menu.Group`, so the context hook had nothing to read. Wrap the GroupLabel in `KMenu.Group` inside the primitive so callers keep the same `` ergonomics. The wrapper adds one extra `
` to the DOM; no padding/layout change since the inner span still owns the spacing. --- src/shared/ui/dropdown-menu.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/shared/ui/dropdown-menu.tsx b/src/shared/ui/dropdown-menu.tsx index de23f2e..e3e4ecb 100644 --- a/src/shared/ui/dropdown-menu.tsx +++ b/src/shared/ui/dropdown-menu.tsx @@ -117,15 +117,23 @@ function DropdownMenuRadioItem(props: ComponentProps & { ); } +/** + * Free-standing menu label (account header in the avatar dropdown, etc.). + * Kobalte's `Menu.GroupLabel` throws "useMenuGroupContext must be used within + * a Menu.Group" when rendered outside a group, so wrap it ourselves. Callers + * stay decoupled from that detail and just write ``. + */ function DropdownMenuLabel(props: ParentProps<{ class?: string; inset?: boolean }>) { const [local, rest] = splitProps(props, ["class", "inset", "children"]); return ( - - {local.children} - + + + {local.children} + + ); } From f80e5fc5ee2d33676c26cbde8cb806ab1df60fcd Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Tue, 2 Jun 2026 12:52:32 +0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(qol):=20borderless=20cards,=20drop=20b?= =?UTF-8?q?rand=20text,=20fix=20wide=E2=86=92narrow=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI polish + responsive fixes: - Card / ShotTile: drop the visible 1px border in favour of shadow-sm + a soft ring (black/0.04 light, white/0.06 dark). Hover on interactive cards lifts to shadow-md. Card radius bumps to xl. - App bar: remove the "Doctorina · Screenshots" word-mark — the tab title + favicon already brand the app. Just the dot stays (plus the FAKE chip in fake-backend mode). The link itself keeps a 36-px tap area so dropping the text didn't shrink the click target to a 10×10 dot. - App bar / layout: harden the wide→narrow resize path. Add `min-w-0` to the app-bar flex container and nav, `shrink-0` to the brand / back-link / right cluster, and `overflow-x-clip` + `min-w-0` on the layout root so an inner flex child measuring its min-content for one frame can't push the page wider than the viewport. `clip` is chosen over `hidden` so the sticky AppBar keeps working. - Run page: the implicit grid track on `
` was sizing to its content's min-width, which at 320px viewport let the Run-header card overflow ~64 px past the right edge (clipped silently by overflow-x-clip). Switch to `grid-cols-1 min-w-0` so the column shrinks. Branch chip in RunHeader also gets `max-w-[9rem] sm:max-w-[14rem]` so the chip itself fits at 320 px. - Run card: language pills keep `select-none` on the decorative per-element spans; Badge variant stays SELECTABLE (we reverted the earlier base-level `select-none` because branch names, PR numbers, and commit SHAs render through Badge and users copy them). --- src/app/app-bar.tsx | 25 +++++++++++++++------ src/app/app-layout.tsx | 12 ++++++++-- src/features/runs/components/run-card.tsx | 2 +- src/features/runs/components/run-header.tsx | 2 +- src/features/runs/components/shot-grid.tsx | 2 +- src/pages/run.tsx | 2 +- src/shared/ui/badge.tsx | 4 ++++ src/shared/ui/card.tsx | 7 ++++-- 8 files changed, 41 insertions(+), 15 deletions(-) diff --git a/src/app/app-bar.tsx b/src/app/app-bar.tsx index e976bf4..9cf0c03 100644 --- a/src/app/app-bar.tsx +++ b/src/app/app-bar.tsx @@ -30,7 +30,13 @@ function AppBar() { const bar = useAppBar(); return (
-
+ {/* + `min-w-0` on the flex container plus `min-w-0` on the wrap-able + middle slot lets long content (commit subjects, branch chips) shrink + instead of pushing the right cluster off-screen when the viewport + is dragged from desktop down to mobile width without a full reload. + */} +
{(back) => ( @@ -38,7 +44,7 @@ function AppBar() { @@ -49,7 +55,10 @@ function AppBar() { )} -