diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 215e8dd..adb9dae 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -20,6 +20,7 @@ import {
Wrench,
X,
} from "lucide-react";
+import { SiteScopeSwitcher } from "@/components/layout/site-scope-switcher";
import { Logo } from "@/components/logo";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
@@ -286,8 +287,21 @@ export function Topbar({ title, subtitle, right }: { title: string; subtitle?: s
{title}
{subtitle &&
{subtitle}
}
+ {/* Mobile: the scope pill gets its own row. The hamburger is
+ position:fixed over the topbar's left edge (line 252), so
+ the two would otherwise compete for that space. */}
+
+
+
+
+
+
+
+
+
+
{right}
diff --git a/src/components/layout/site-scope-switcher.tsx b/src/components/layout/site-scope-switcher.tsx
new file mode 100644
index 0000000..6ee33fd
--- /dev/null
+++ b/src/components/layout/site-scope-switcher.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { Building2, Check, ChevronDown, LayoutGrid } from "lucide-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useSiteScope } from "@/components/layout/site-scope-provider";
+import {
+ resolveActive,
+ SITE_SCOPE_ALL,
+ SITE_SCOPE_COOKIE,
+ SITE_SCOPE_PARAM,
+} from "@/lib/site-scope";
+import { cn } from "@/lib/utils";
+
+/** One year, in seconds. */
+const COOKIE_MAX_AGE = 31_536_000;
+
+function useScopeState() {
+ const { allowed, fallbackValue } = useSiteScope();
+ const params = useSearchParams();
+ const pathname = usePathname();
+ const router = useRouter();
+
+ // Same pure function the server pages call, with the same inputs, so
+ // the pill can never disagree with the data below it.
+ const { active, mode } = resolveActive(
+ allowed,
+ params.get(SITE_SCOPE_PARAM) ?? undefined,
+ fallbackValue
+ );
+
+ const label =
+ allowed.length === 0
+ ? "No sites assigned"
+ : mode === "single"
+ ? (active[0]?.name ?? "No sites assigned")
+ : `All sites · ${allowed.length}`;
+
+ const selected = mode === "single" ? (active[0]?.id ?? SITE_SCOPE_ALL) : SITE_SCOPE_ALL;
+
+ const setScope = React.useCallback(
+ (value: string) => {
+ document.cookie = `${SITE_SCOPE_COOKIE}=${value}; path=/; max-age=${COOKIE_MAX_AGE}; samesite=lax`;
+ const next = new URLSearchParams(params.toString());
+ next.set(SITE_SCOPE_PARAM, value);
+ // replace, not push: a scope change is not a navigation step, and
+ // filling the back stack with scope toggles makes Back unusable.
+ // pathname, not a fixed route: switching must keep you on the page
+ // you are on.
+ router.replace(`${pathname}?${next.toString()}`, { scroll: false });
+ },
+ [params, pathname, router]
+ );
+
+ return { allowed, label, selected, setScope, interactive: allowed.length > 1 };
+}
+
+function Pill({
+ label,
+ interactive,
+ className,
+}: {
+ label: string;
+ interactive: boolean;
+ className?: string;
+}) {
+ return (
+
+
+ {label}
+ {interactive && }
+
+ );
+}
+
+function Options({
+ allowed,
+ selected,
+ onPick,
+ Row,
+}: {
+ allowed: ReturnType["allowed"];
+ selected: string;
+ onPick: (value: string) => void;
+ Row: React.ComponentType<{ onSelect: () => void; children: React.ReactNode }>;
+}) {
+ return (
+ <>
+ {allowed.map((s) => (
+ onPick(s.id)}>
+ {selected === s.id ? (
+
+ ) : (
+
+ )}
+ {s.name}
+
+ ))}
+ onPick(SITE_SCOPE_ALL)}>
+ {selected === SITE_SCOPE_ALL ? (
+
+ ) : (
+
+ )}
+ All my sites
+ {allowed.length}
+
+ >
+ );
+}
+
+export function SiteScopeSwitcher({ variant }: { variant: "dropdown" | "sheet" }) {
+ const { allowed, label, selected, setScope, interactive } = useScopeState();
+ const [open, setOpen] = React.useState(false);
+
+ // One allowed site (or none): a static label. Nothing to tap — a
+ // dropdown with a single entry is noise. It still shows, because
+ // knowing which site a margin belongs to is the point.
+ if (!interactive) return ;
+
+ if (variant === "dropdown") {
+ return (
+
+
+
+
+
+ Your sites
+ (
+
+ {children}
+
+ )}
+ />
+
+
+
+ );
+ }
+
+ // Bottom sheet. Built from the Radix primitives rather than our
+ // DialogContent, whose `left-1/2 top-1/2 -translate-*` centring would
+ // have to be fought class-by-class to anchor at the bottom.
+ return (
+
+
+
+
+
+
+
+
+ Which site?
+
+