Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions firebase/firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,59 @@
],
"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" }
]
},
{
"source": "/assets/**/*.js",
"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=()" }
]
}
]
}
Expand Down
25 changes: 18 additions & 7 deletions src/app/app-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,21 @@ function AppBar() {
const bar = useAppBar();
return (
<header class="sticky top-0 z-30 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto flex h-14 w-full max-w-screen-2xl items-center gap-2 px-3 sm:gap-3 sm:px-6">
{/*
`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.
*/}
<div class="mx-auto flex h-14 w-full min-w-0 max-w-screen-2xl items-center gap-2 px-3 sm:gap-3 sm:px-6">
<BrandLink />
<Show when={bar.back()}>
{(back) => (
<Tooltip>
<TooltipTrigger
as={A}
href={back().href}
class="inline-flex h-9 select-none items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
class="inline-flex h-9 shrink-0 select-none items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label={t(back().labelKey)}
>
<BackIcon />
Expand All @@ -49,7 +55,10 @@ function AppBar() {
)}
</Show>
<Show when={auth.user()}>
<nav class="ml-1 hidden gap-1 text-sm text-muted-foreground md:flex" aria-label="Primary">
<nav
class="ml-1 hidden min-w-0 gap-1 text-sm text-muted-foreground md:flex"
aria-label="Primary"
>
<A
href="/"
end
Expand All @@ -67,7 +76,7 @@ function AppBar() {
</A>
</nav>
</Show>
<div class="ml-auto flex items-center gap-1 sm:gap-2">
<div class="ml-auto flex shrink-0 items-center gap-1 sm:gap-2">
<Show when={bar.filters()}>
{(f) => (
<Popover>
Expand Down Expand Up @@ -119,19 +128,21 @@ function AppBar() {
function BrandLink() {
const t = useT();
return (
// Icon-only brand: the word-mark was pure noise in a viewer that already
// has the tab title + favicon. The link itself keeps an `h-9` hit area so
// dropping the text didn't reduce the tap target to a 10×10 dot.
<A
href="/"
class="flex select-none items-center gap-2 font-semibold tracking-tight"
class="inline-flex h-9 shrink-0 select-none items-center gap-2 px-1 font-semibold tracking-tight"
aria-label={t("brand.name")}
>
<span aria-hidden="true" class="inline-block h-2.5 w-2.5 shrink-0 rounded-full bg-primary" />
<span class="hidden truncate sm:inline">{t("brand.name")}</span>
<Show when={USE_FAKE_BACKEND}>
<Tooltip openDelay={250}>
<TooltipTrigger
as={Badge}
variant="warning"
class="ml-1 font-mono text-[10px] tracking-wider"
class="font-mono text-[10px] tracking-wider"
title={t("brand.fakeTooltip")}
aria-label={t("brand.fakeTooltip")}
>
Expand Down
12 changes: 10 additions & 2 deletions src/app/app-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,17 @@ import { AppBarProvider } from "@/app/app-bar-context";
function AppLayout(props: ParentProps) {
return (
<AppBarProvider>
<div class="flex min-h-dvh flex-col">
{/*
`overflow-x-clip` on the root container kills the brief horizontal
scrollbar that appeared when a user resized the window from desktop
down to mobile: an inner flex child briefly measures its
min-content width before the next reflow shrinks it back. `clip` is
preferable to `hidden` here — it doesn't create a new scroll
container, so position:sticky on the app bar keeps working.
*/}
<div class="flex min-h-dvh w-full min-w-0 flex-col overflow-x-clip">
<AppBar />
<main class="mx-auto w-full max-w-screen-2xl flex-1 px-3 py-5 sm:px-6 sm:py-8">
<main class="mx-auto w-full min-w-0 max-w-screen-2xl flex-1 px-3 py-5 sm:px-6 sm:py-8">
{props.children}
</main>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/features/runs/components/run-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function RunCard(props: RunCardProps) {
<div class="flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
<For each={props.run.languages.slice(0, LANGUAGES_PREVIEW)}>
{(lang) => (
<span class="rounded bg-muted px-1.5 py-0.5 font-mono uppercase text-muted-foreground">
<span class="select-none rounded bg-muted px-1.5 py-0.5 font-mono uppercase text-muted-foreground">
{lang}
</span>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/features/runs/components/run-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function RunHeader(props: RunHeaderProps) {
<TooltipTrigger
as={Badge}
variant="outline"
class="max-w-[14rem] truncate font-mono text-[11px] uppercase tracking-wide"
class="max-w-[9rem] truncate font-mono text-[11px] uppercase tracking-wide sm:max-w-[14rem]"
>
{props.run.git.branch}
</TooltipTrigger>
Expand Down
2 changes: 1 addition & 1 deletion src/features/runs/components/shot-grid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ function ShotTile(props: ShotTileProps) {
<button
type="button"
onClick={props.onClick}
class="group relative flex aspect-[9/16] select-none overflow-hidden rounded-md border bg-muted/40 text-left ring-offset-background transition will-change-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
class="group relative flex aspect-[9/16] select-none overflow-hidden rounded-md bg-muted/40 text-left shadow-sm ring-1 ring-black/[0.04] ring-offset-background transition will-change-transform hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 dark:ring-white/[0.06]"
aria-label={`${props.shot.caseId} · ${props.shot.languageCode} · ${props.shot.layoutId}`}
>
<Show
Expand Down
2 changes: 1 addition & 1 deletion src/pages/run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ function RunPage() {
const flatShots = createMemo(() => store.groups().flatMap((g) => g.shots));

return (
<div class="grid gap-5">
<div class="grid min-w-0 grid-cols-1 gap-5">
<Show when={!store.state().loading} fallback={<HeaderSkeleton />}>
<Show
when={store.state().run}
Expand Down
15 changes: 13 additions & 2 deletions src/shared/api/firebase.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions src/shared/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { splitProps } from "solid-js";
import { cn } from "@/shared/lib/cn";

const badgeVariants = cva(
// Note: Badge stays selectable by default. Callers select+copy branch
// names, PR numbers, and commit SHAs that are rendered through Badge —
// baking `select-none` in would silently break that. Decorative chips
// (e.g. language pills on run cards) opt OUT per-call via `class`.
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
Expand Down
7 changes: 5 additions & 2 deletions src/shared/ui/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ function Card(props: CardProps) {
<div
use:ripple={local.interactive || undefined}
class={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
// Borderless: rely on a soft elevation that lifts slightly on hover
// for interactive cards. Subtle ring stays only in dark mode to keep
// the card boundary readable against an almost-equal background.
"rounded-xl bg-card text-card-foreground shadow-sm ring-1 ring-black/[0.04] dark:ring-white/[0.06]",
local.interactive &&
"cursor-pointer transition duration-150 hover:bg-accent/50 active:scale-[0.99]",
"cursor-pointer transition-[transform,box-shadow,background-color] duration-150 hover:bg-accent/40 hover:shadow-md active:scale-[0.99]",
local.class,
)}
{...rest}
Expand Down
20 changes: 14 additions & 6 deletions src/shared/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,23 @@ function DropdownMenuRadioItem(props: ComponentProps<typeof KMenu.RadioItem> & {
);
}

/**
* 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 `<DropdownMenuLabel>`.
*/
function DropdownMenuLabel(props: ParentProps<{ class?: string; inset?: boolean }>) {
const [local, rest] = splitProps(props, ["class", "inset", "children"]);
return (
<KMenu.GroupLabel
class={cn("px-2 py-1.5 text-sm font-semibold", local.inset && "pl-8", local.class)}
{...rest}
>
{local.children}
</KMenu.GroupLabel>
<KMenu.Group>
<KMenu.GroupLabel
class={cn("px-2 py-1.5 text-sm font-semibold", local.inset && "pl-8", local.class)}
{...rest}
>
{local.children}
</KMenu.GroupLabel>
</KMenu.Group>
);
}

Expand Down
42 changes: 38 additions & 4 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},
],
},
}),
],
Expand Down
Loading