From 408a8443bac0e009b7deb52743f7e74134952d1c Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sat, 2 May 2026 23:46:17 -0700 Subject: [PATCH 1/4] feat: SPA topbar with repo name and theme toggle Adds a sticky topbar above the chapter UI showing the git repo basename on the left (new `repoName` field on the chapters response) and a Sun/Moon/ System dropdown on the right that persists to `localStorage["ui-theme"]`. Mirrors the hosted stage monorepo's theme system: an inline IIFE in index.html applies the persisted class before paint to avoid FOUC. Refactors PullRequestLayout to use a shared `useChapters(runId)` hook so the topbar and the chapter list share one cache entry, and offsets the sticky tab nav (`top-12`) and `--content-top` to clear the new topbar. --- packages/cli/src/routes/runs.ts | 3 +- packages/types/src/chapters.ts | 1 + packages/web/index.html | 20 ++ packages/web/package.json | 1 + packages/web/src/App.tsx | 11 +- .../src/components/layout/theme-toggle.tsx | 46 +++ packages/web/src/components/layout/topbar.tsx | 18 ++ .../web/src/components/ui/dropdown-menu.tsx | 59 ++++ packages/web/src/lib/theme.tsx | 106 +++++++ packages/web/src/lib/use-chapters.ts | 21 ++ packages/web/src/main.tsx | 9 +- .../web/src/routes/pull-request-layout.tsx | 21 +- packages/web/src/styles/globals.css | 7 +- pnpm-lock.yaml | 293 +++++++++++++++++- 14 files changed, 589 insertions(+), 27 deletions(-) create mode 100644 packages/web/src/components/layout/theme-toggle.tsx create mode 100644 packages/web/src/components/layout/topbar.tsx create mode 100644 packages/web/src/components/ui/dropdown-menu.tsx create mode 100644 packages/web/src/lib/theme.tsx create mode 100644 packages/web/src/lib/use-chapters.ts diff --git a/packages/cli/src/routes/runs.ts b/packages/cli/src/routes/runs.ts index e66bcb9..15d9e7b 100644 --- a/packages/cli/src/routes/runs.ts +++ b/packages/cli/src/routes/runs.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import type { Chapter, ChapterRun, KeyChange } from "@stage-cli/types/chapters"; import { asc, eq, inArray } from "drizzle-orm"; import type { StageDb } from "../db/client.js"; @@ -35,7 +36,7 @@ function mapChapter(ch: ChapterRow, kcs: KeyChangeRow[]): Chapter { } function mapRun(run: ChapterRunRow): ChapterRun { - return { id: run.id }; + return { id: run.id, repoName: path.basename(run.repoRoot) }; } export function runRoutes(db: StageDb): Route[] { diff --git a/packages/types/src/chapters.ts b/packages/types/src/chapters.ts index 784e5db..91cf457 100644 --- a/packages/types/src/chapters.ts +++ b/packages/types/src/chapters.ts @@ -48,6 +48,7 @@ export type Chapter = z.infer; export const ChapterRunSchema = z.object({ id: z.string(), + repoName: z.string(), }); export type ChapterRun = z.infer; diff --git a/packages/web/index.html b/packages/web/index.html index 14634d5..e9dcf2d 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -4,6 +4,26 @@ Stage CLI +
diff --git a/packages/web/package.json b/packages/web/package.json index 8827907..4485534 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -14,6 +14,7 @@ "@stage-cli/types": "workspace:*", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index a58749c..97114e9 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,9 +1,10 @@ +import { Topbar } from "@/components/layout/topbar"; import { useHashRunId } from "@/lib/use-hash-run-id"; import { PullRequestLayout } from "@/routes/pull-request-layout"; function NoRunSelected() { return ( -
+

No run selected

@@ -17,6 +18,10 @@ function NoRunSelected() { export function App() { const runId = useHashRunId(); - if (!runId) return ; - return ; + return ( +

+ + {runId ? : } +
+ ); } diff --git a/packages/web/src/components/layout/theme-toggle.tsx b/packages/web/src/components/layout/theme-toggle.tsx new file mode 100644 index 0000000..d8122bc --- /dev/null +++ b/packages/web/src/components/layout/theme-toggle.tsx @@ -0,0 +1,46 @@ +import { Monitor, Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { USER_THEME, type UserTheme, useTheme } from "@/lib/theme"; + +interface Option { + value: UserTheme; + label: string; + icon: React.ElementType; +} + +const OPTIONS: Option[] = [ + { value: USER_THEME.LIGHT, label: "Light", icon: Sun }, + { value: USER_THEME.DARK, label: "Dark", icon: Moon }, + { value: USER_THEME.SYSTEM, label: "System", icon: Monitor }, +]; + +export function ThemeToggle() { + const { userTheme, setTheme } = useTheme(); + + return ( + + + + + + {OPTIONS.map(({ value, label, icon: Icon }) => ( + setTheme(value)} className="cursor-pointer"> + + {label} + {userTheme === value && } + + ))} + + + ); +} diff --git a/packages/web/src/components/layout/topbar.tsx b/packages/web/src/components/layout/topbar.tsx new file mode 100644 index 0000000..eef7c64 --- /dev/null +++ b/packages/web/src/components/layout/topbar.tsx @@ -0,0 +1,18 @@ +import { ThemeToggle } from "@/components/layout/theme-toggle"; +import { useChapters } from "@/lib/use-chapters"; + +export function Topbar({ runId }: { runId: string | null }) { + const { data } = useChapters(runId); + const repoName = data?.run.repoName; + + return ( +
+
+ {repoName && {repoName}} +
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/ui/dropdown-menu.tsx b/packages/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..b358174 --- /dev/null +++ b/packages/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,59 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: React.ComponentProps) { + return ; +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +export { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger }; diff --git a/packages/web/src/lib/theme.tsx b/packages/web/src/lib/theme.tsx new file mode 100644 index 0000000..a0e1031 --- /dev/null +++ b/packages/web/src/lib/theme.tsx @@ -0,0 +1,106 @@ +import { + createContext, + type ReactNode, + use, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +export const USER_THEME = { + LIGHT: "light", + DARK: "dark", + SYSTEM: "system", +} as const; +export type UserTheme = (typeof USER_THEME)[keyof typeof USER_THEME]; + +export const APP_THEME = { + LIGHT: "light", + DARK: "dark", +} as const; +export type AppTheme = (typeof APP_THEME)[keyof typeof APP_THEME]; + +export const THEME_STORAGE_KEY = "ui-theme"; + +const VALID_THEMES: ReadonlySet = new Set(Object.values(USER_THEME)); + +function isValidUserTheme(value: string): value is UserTheme { + return VALID_THEMES.has(value); +} + +function getStoredUserTheme(): UserTheme { + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (stored && isValidUserTheme(stored)) return stored; + } catch { + // localStorage unavailable + } + return USER_THEME.SYSTEM; +} + +function getSystemTheme(): AppTheme { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? APP_THEME.DARK + : APP_THEME.LIGHT; +} + +function applyThemeToDOM(userTheme: UserTheme): void { + const root = document.documentElement; + root.classList.remove(APP_THEME.LIGHT, APP_THEME.DARK); + const resolved = userTheme === USER_THEME.SYSTEM ? getSystemTheme() : userTheme; + root.classList.add(resolved); +} + +interface ThemeContextValue { + userTheme: UserTheme; + appTheme: AppTheme; + setTheme: (theme: UserTheme) => void; +} + +const ThemeContext = createContext(undefined); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [userTheme, setUserTheme] = useState(getStoredUserTheme); + const [systemTheme, setSystemTheme] = useState(getSystemTheme); + + useEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = () => { + const next = mq.matches ? APP_THEME.DARK : APP_THEME.LIGHT; + setSystemTheme(next); + if (userTheme === USER_THEME.SYSTEM) { + applyThemeToDOM(USER_THEME.SYSTEM); + } + }; + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [userTheme]); + + const appTheme: AppTheme = userTheme === USER_THEME.SYSTEM ? systemTheme : userTheme; + + const setTheme = useCallback((next: UserTheme) => { + setUserTheme(next); + try { + localStorage.setItem(THEME_STORAGE_KEY, next); + } catch { + // localStorage unavailable + } + applyThemeToDOM(next); + }, []); + + const contextValue = useMemo( + () => ({ userTheme, appTheme, setTheme }), + [userTheme, appTheme, setTheme], + ); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const ctx = use(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx; +} diff --git a/packages/web/src/lib/use-chapters.ts b/packages/web/src/lib/use-chapters.ts new file mode 100644 index 0000000..5939d82 --- /dev/null +++ b/packages/web/src/lib/use-chapters.ts @@ -0,0 +1,21 @@ +import { type ChaptersResponse, ChaptersResponseSchema } from "@stage-cli/types/chapters"; +import { skipToken, useQuery } from "@tanstack/react-query"; +import { jsonFetch } from "@/lib/use-view-state"; + +async function fetchChapters(runId: string): Promise { + // Parse at the boundary — schema drift surfaces here as a query error, + // not as a render crash deeper in the component tree. + const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/chapters`); + return ChaptersResponseSchema.parse(raw); +} + +/** + * Shared chapters query. Multiple components calling this hook with the same + * runId dedupe to a single network fetch via TanStack Query's cache. + */ +export function useChapters(runId: string | null) { + return useQuery({ + queryKey: ["chapters", runId], + queryFn: runId === null ? skipToken : () => fetchChapters(runId), + }); +} diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index b989cb7..86cb049 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; +import { ThemeProvider } from "./lib/theme"; import "./styles/globals.css"; const rootElement = document.getElementById("root"); @@ -22,8 +23,10 @@ const queryClient = new QueryClient({ createRoot(rootElement).render( - - - + + + + + , ); diff --git a/packages/web/src/routes/pull-request-layout.tsx b/packages/web/src/routes/pull-request-layout.tsx index 0992c42..b810ab3 100644 --- a/packages/web/src/routes/pull-request-layout.tsx +++ b/packages/web/src/routes/pull-request-layout.tsx @@ -1,10 +1,9 @@ -import { type ChaptersResponse, ChaptersResponseSchema } from "@stage-cli/types/chapters"; -import { useQuery } from "@tanstack/react-query"; import { BookOpen, FileText } from "lucide-react"; import { useMemo, useState } from "react"; import { SectionLabel } from "@/components/pull-request/section-label"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { jsonFetch, useViewStateData } from "@/lib/use-view-state"; +import { useChapters } from "@/lib/use-chapters"; +import { useViewStateData } from "@/lib/use-view-state"; import { cn } from "@/lib/utils"; import { ChaptersIndexPage } from "./chapters-index-page"; @@ -77,7 +76,7 @@ function TabLink({ tab, isActive, onSelect, countLabel }: TabLinkProps) { function ErrorState({ error }: { error: unknown }) { return ( -
+

Couldn't load chapters

@@ -89,15 +88,7 @@ function ErrorState({ error }: { error: unknown }) { } export function PullRequestLayout({ runId }: { runId: string }) { - const { data, isLoading, error } = useQuery({ - queryKey: ["chapters", runId], - // Parse at the boundary — schema drift surfaces here as a query error, - // not as a render crash inside ChaptersIndexPage. - queryFn: async () => { - const raw = await jsonFetch(`/api/runs/${encodeURIComponent(runId)}/chapters`); - return ChaptersResponseSchema.parse(raw); - }, - }); + const { data, isLoading, error } = useChapters(runId); const [activeTab, setActiveTab] = useState(PR_TAB.CHAPTERS); // Lift the viewed count out of ChaptersIndexPage so the tab strip can render @@ -125,13 +116,13 @@ export function PullRequestLayout({ runId }: { runId: string }) { if (error) return ; return ( -

+
Run

{data?.run.id ?? runId}

-