From 57beb0d4c7550063cf861df358f3e7915670f976 Mon Sep 17 00:00:00 2001 From: "Peter (Claude Code)" Date: Wed, 17 Jun 2026 01:29:40 +0000 Subject: [PATCH] feat(layout): standardized two-line PageBar (title + subtitle) Redesign the global PageBar from a single-line title strip into the standardized two-line header: bold title (#1C1C1E) + one-sentence secondary subtitle (#8E8E93), sourced from resolvePageMeta with context override still honored. - Extend page-bar-context with a subtitle override (usePageSubtitle), mirroring the undefined=resolve / null=suppress / string=explicit semantics already used for the title. A null title still suppresses the whole bar; a null/empty subtitle just drops the second line. - Grow the bar to fit two lines; the subtitle line is omitted when empty. - Seed page-meta registry entries for change-requests, flags, epics (and add an "epics" SEGMENT_LABELS fallback now that the explicit usePageTitle("Epics") is gone). - Migrate those 3 pages off the in-body ui/PageHeader: remove their duplicate title/subtitle so the global bar is the single source, and keep the primary action on the filter-tabs toolbar. Flags keeps its live count via usePageSubtitle. Builds on the page-meta registry (#1147). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../change-requests-client.tsx | 38 +++++++------ .../p/[projectSlug]/epics/epics-client.tsx | 43 ++++++++------- .../p/[projectSlug]/flags/flags-client.tsx | 53 ++++++++++--------- src/components/layout/page-bar-context.tsx | 51 ++++++++++++++---- src/components/layout/page-bar.tsx | 22 +++++--- src/components/layout/page-meta.ts | 28 ++++++++++ 6 files changed, 150 insertions(+), 85 deletions(-) diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/change-requests/change-requests-client.tsx b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/change-requests/change-requests-client.tsx index 0d01f4fd7..63c51dd59 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/change-requests/change-requests-client.tsx +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/change-requests/change-requests-client.tsx @@ -22,7 +22,6 @@ import { toast } from "sonner"; import { EmptyState } from "@/components/ui/empty-state"; import { ModalShell, ModalFooter } from "@/components/ui/modal"; import { FilterTabs } from "@/components/ui/filter-tabs"; -import { PageHeader } from "@/components/ui/page-header"; import { MarkdownEditor } from "@/components/markdown/markdown-editor"; function copyItemLink(id: string): void { @@ -671,25 +670,24 @@ export default function ChangeRequestsClient({ return (
- setShowModal(true)} - className="flex items-center gap-1.5 rounded-full bg-[#4CD964] px-4 py-2 text-sm font-semibold text-white hover:bg-[#3DBF55] transition-colors" - > - - New Request - - } - /> - - + {/* Title + subtitle now live in the global PageBar (see page-meta.ts); + this toolbar keeps the primary action alongside the filter tabs. */} +
+ + +
{/* CR list */}
diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/epics/epics-client.tsx b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/epics/epics-client.tsx index 2a0fe94e7..64a468605 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/epics/epics-client.tsx +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/epics/epics-client.tsx @@ -29,11 +29,9 @@ import { toast } from "sonner"; import { pushUndoEntry } from "@/lib/hooks/use-undo-stack"; import { EmptyState } from "@/components/ui/empty-state"; import { FilterTabs } from "@/components/ui/filter-tabs"; -import { PageHeader } from "@/components/ui/page-header"; import { ModalShell, ModalFooter } from "@/components/ui/modal"; import { MarkdownEditor } from "@/components/markdown/markdown-editor"; import { MarkdownRenderer } from "@/components/markdown/markdown-renderer"; -import { usePageTitle } from "@/components/layout/page-bar-context"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -588,8 +586,8 @@ export function EpicsClient({ currentUserId: _currentUserId, initialSelectedId, }: Props) { - usePageTitle("Epics"); - + // Title + subtitle resolve from the page-meta registry (the global PageBar is + // the single source) — no in-page override needed. const router = useRouter(); const pathname = usePathname(); @@ -632,24 +630,25 @@ export function EpicsClient({ return (
- {/* Header */} - setShowCreate(true)} - className="flex items-center gap-1.5 rounded-full bg-[#4CD964] px-4 py-2 text-sm font-medium text-white hover:bg-[#3DBF55] transition-colors" - > - - New Epic - - } - /> - - {/* Filter tabs */} - + {/* Title + subtitle now live in the global PageBar (see page-meta.ts); + this toolbar keeps the primary action alongside the filter tabs. */} +
+ + +
{/* Content */}
diff --git a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/flags/flags-client.tsx b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/flags/flags-client.tsx index 510fe3183..e5e458341 100644 --- a/src/app/(app)/o/[orgSlug]/p/[projectSlug]/flags/flags-client.tsx +++ b/src/app/(app)/o/[orgSlug]/p/[projectSlug]/flags/flags-client.tsx @@ -20,7 +20,7 @@ import { pushUndoEntry } from "@/lib/hooks/use-undo-stack"; import { EmptyState } from "@/components/ui/empty-state"; import { ModalShell, ModalFooter } from "@/components/ui/modal"; import { FilterTabs } from "@/components/ui/filter-tabs"; -import { PageHeader } from "@/components/ui/page-header"; +import { usePageSubtitle } from "@/components/layout/page-bar-context"; import { MarkdownEditor } from "@/components/markdown/markdown-editor"; import { MarkdownRenderer } from "@/components/markdown/markdown-renderer"; @@ -530,6 +530,13 @@ export default function FlagsClient({ }: Props) { usePageSection("Flags"); const [flags, setFlags] = useState(initialFlags); + // Surface the live flag count in the global PageBar's subtitle line. This is a + // runtime-dynamic override of the static registry subtitle for this route. + usePageSubtitle( + flags.length === 0 + ? "No flags raised yet" + : `${flags.length} flag${flags.length !== 1 ? "s" : ""} total`, + ); const [filter, setFilter] = useState("all"); const [showModal, setShowModal] = useState(false); const [editingFlag, setEditingFlag] = useState(null); @@ -652,31 +659,25 @@ export default function FlagsClient({ return (
- - - Raise Flag - - } - /> - - + {/* Title + subtitle now live in the global PageBar (registry + the + usePageSubtitle override above); this toolbar keeps the primary action + alongside the filter tabs. */} +
+ + +
{/* ── Flag list ──────────────────────────────────────────────────────── */}
diff --git a/src/components/layout/page-bar-context.tsx b/src/components/layout/page-bar-context.tsx index f9626b8ba..e4119445c 100644 --- a/src/components/layout/page-bar-context.tsx +++ b/src/components/layout/page-bar-context.tsx @@ -4,22 +4,31 @@ import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"; -// undefined = not declared by page (PageBar infers from pathname) -// null = page suppresses the bar title (page owns its own heading) -// string = page declares an explicit title for the bar -type PageTitleState = string | null | undefined; +// undefined = not declared by page (PageBar resolves from the page-meta registry) +// null = page suppresses the value (title null suppresses the whole bar; +// subtitle null suppresses just the subtitle line) +// string = page declares an explicit value for the bar +type PageMetaState = string | null | undefined; interface PageBarCtxValue { - title: PageTitleState; - setTitle: (t: PageTitleState) => void; + title: PageMetaState; + setTitle: (t: PageMetaState) => void; + subtitle: PageMetaState; + setSubtitle: (s: PageMetaState) => void; } const PageBarCtx = createContext(null); export function PageBarProvider({ children }: { children: ReactNode }) { - const [title, setTitleState] = useState(undefined); - const setTitle = useCallback((t: PageTitleState) => setTitleState(t), []); - return {children}; + const [title, setTitleState] = useState(undefined); + const [subtitle, setSubtitleState] = useState(undefined); + const setTitle = useCallback((t: PageMetaState) => setTitleState(t), []); + const setSubtitle = useCallback((s: PageMetaState) => setSubtitleState(s), []); + return ( + + {children} + + ); } export function usePageBarCtx() { @@ -30,7 +39,10 @@ export function usePageBarCtx() { * Declare or suppress the page bar title from inside a page component. * * - usePageTitle("Tasks") → bar shows "Tasks" - * - usePageTitle(null) → bar shows no title (page manages its own heading) + * - usePageTitle(null) → bar shows no title (the whole bar is suppressed) + * + * Leaves the subtitle untouched — the bar still resolves the subtitle from the + * page-meta registry unless the page also calls `usePageSubtitle`. */ export function usePageTitle(title: string | null) { const ctx = useContext(PageBarCtx); @@ -40,3 +52,22 @@ export function usePageTitle(title: string | null) { return () => setTitle?.(undefined); }, [title, setTitle]); } + +/** + * Override or suppress the page bar subtitle from inside a page component. + * + * - usePageSubtitle("3 flags total") → bar's second line shows that text + * - usePageSubtitle(null) → bar shows the title only (no subtitle line) + * + * When a page does not call this hook the bar resolves the subtitle from the + * page-meta registry. Useful for runtime-dynamic subtitles (e.g. live counts) + * that the static registry cannot express. + */ +export function usePageSubtitle(subtitle: string | null) { + const ctx = useContext(PageBarCtx); + const setSubtitle = ctx?.setSubtitle; + useEffect(() => { + setSubtitle?.(subtitle); + return () => setSubtitle?.(undefined); + }, [subtitle, setSubtitle]); +} diff --git a/src/components/layout/page-bar.tsx b/src/components/layout/page-bar.tsx index 560712a89..bef912a6d 100644 --- a/src/components/layout/page-bar.tsx +++ b/src/components/layout/page-bar.tsx @@ -5,7 +5,7 @@ import { usePathname } from "next/navigation"; import { usePageBarCtx } from "./page-bar-context"; -import { resolvePageTitle } from "./page-meta"; +import { resolvePageMeta, resolvePageTitle } from "./page-meta"; // SEGMENT_LABELS now lives in ./page-meta as the canonical title fallback. // Re-exported here for backward compatibility with existing importers. @@ -23,18 +23,26 @@ export function inferTitle(pathname: string): string { export function PageBar() { const pathname = usePathname(); + const meta = resolvePageMeta(pathname); const ctx = usePageBarCtx(); - // ctx.title === undefined → infer from pathname - // ctx.title === null → page suppresses bar title (shows nothing) - // ctx.title === string → page declares explicit title - const displayTitle = ctx?.title === undefined ? inferTitle(pathname) : ctx.title; + // For both title and subtitle: + // ctx value === undefined → resolve from the page-meta registry + // ctx value === null → page suppresses it + // ctx value === string → page declares an explicit value + // A null/empty title suppresses the whole bar; a null/empty subtitle just + // drops the second line. + const displayTitle = ctx?.title === undefined ? meta.title : ctx.title; + const displaySubtitle = ctx?.subtitle === undefined ? meta.subtitle : ctx.subtitle; if (!displayTitle) return null; return ( -
- {displayTitle} +
+ {displayTitle} + {displaySubtitle ? ( + {displaySubtitle} + ) : null}
); } diff --git a/src/components/layout/page-meta.ts b/src/components/layout/page-meta.ts index 6fcf6c853..2f683a0ff 100644 --- a/src/components/layout/page-meta.ts +++ b/src/components/layout/page-meta.ts @@ -65,6 +65,7 @@ export const SEGMENT_LABELS: Record = { technologies: "Technologies", feedback: "Feedback", flags: "Flags", + epics: "Epics", "change-requests": "Change Requests", approvals: "Approvals", "my-approvals": "My Approvals", @@ -161,6 +162,33 @@ export const PAGE_META: Record = { actions: ["Draft and edit specs", "Score scope clarity"], }, }, + "/o/[org]/p/[project]/change-requests": { + title: "Change Requests", + subtitle: "Track and approve formal changes to this project's scope", + guide: { + summary: + "Change requests capture formal scope changes through a draft → submitted → approved/rejected workflow, keeping scope decisions auditable.", + actions: ["Raise a new change request", "Review, approve, or reject pending requests"], + }, + }, + "/o/[org]/p/[project]/flags": { + title: "Flags", + subtitle: "Risks, blockers, and concerns raised on this project", + guide: { + summary: + "Flags surface risks, blockers, and concerns so the team can track and resolve them before they impact delivery.", + actions: ["Raise a flag against the project", "Filter flags by status and severity"], + }, + }, + "/o/[org]/p/[project]/epics": { + title: "Epics", + subtitle: "Thematic groups of tasks synced with GitHub issues", + guide: { + summary: + "Epics group related tasks into themes and stay in sync with their GitHub issues, giving a higher-level view of project work.", + actions: ["Create an epic and link tasks to it", "Filter epics by status"], + }, + }, }; /** The three route levels the resolver understands. */