|
| 1 | +# Header System Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Goal:** Unify all web headers into three fixed public components: `PageHeader`, `PanelHeader`, and `DialogHeader`. |
| 6 | + |
| 7 | +**Architecture:** Keep `PageHeader` as the shared navigation header, add a new `PanelHeader` for dense surface chrome, and make modal headers the canonical `DialogHeader`. Existing page-specific headers should be migrated onto these primitives without introducing any new ad hoc header shapes. |
| 8 | + |
| 9 | +**Tech Stack:** React, TypeScript, Vitest, Testing Library, CSS modules / global component CSS |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +### Task 1: Extend the shared page header primitive |
| 14 | + |
| 15 | +**Files:** |
| 16 | +- Modify: `packages/web/src/features/shared/components/page-header.tsx` |
| 17 | +- Modify: `packages/web/src/features/shared/components/page-header.test.tsx` |
| 18 | +- Modify: `packages/web/src/features/shared/components/mobile-page-header.tsx` |
| 19 | +- Modify: `packages/web/src/features/shared/components/mobile-page-header.test.tsx` |
| 20 | +- Modify: `packages/web/src/styles/components.css` |
| 21 | + |
| 22 | +- [ ] **Step 1: Write the failing test** |
| 23 | + |
| 24 | +```tsx |
| 25 | +import { render, screen } from "@testing-library/react"; |
| 26 | +import { describe, expect, it, vi } from "vitest"; |
| 27 | +import { PageHeader } from "./page-header"; |
| 28 | + |
| 29 | +describe("PageHeader", () => { |
| 30 | + it("supports primary and secondary levels with distinct title sizing hooks", () => { |
| 31 | + render( |
| 32 | + <> |
| 33 | + <PageHeader title="Primary" level="primary" onBack={vi.fn()} /> |
| 34 | + <PageHeader title="Secondary" level="secondary" onBack={vi.fn()} /> |
| 35 | + </> |
| 36 | + ); |
| 37 | + |
| 38 | + expect(document.querySelectorAll(".page-header--primary").length).toBe(1); |
| 39 | + expect(document.querySelectorAll(".page-header--secondary").length).toBe(1); |
| 40 | + }); |
| 41 | +}); |
| 42 | +``` |
| 43 | + |
| 44 | +- [ ] **Step 2: Run the test to verify it fails** |
| 45 | + |
| 46 | +Run: `pnpm vitest packages/web/src/features/shared/components/page-header.test.tsx -t "supports primary and secondary levels with distinct title sizing hooks" -v` |
| 47 | +Expected: FAIL because `level` and the modifier classes do not exist yet. |
| 48 | + |
| 49 | +- [ ] **Step 3: Write the minimal implementation** |
| 50 | + |
| 51 | +```tsx |
| 52 | +export interface PageHeaderProps { |
| 53 | + title: string; |
| 54 | + level?: "primary" | "secondary"; |
| 55 | + onBack?: () => void; |
| 56 | + backLabel?: string; |
| 57 | + backAriaLabel?: string; |
| 58 | + kicker?: ReactNode; |
| 59 | + rightSlot?: ReactNode; |
| 60 | + titleAs?: PageHeaderTitleElement; |
| 61 | + className?: string; |
| 62 | +} |
| 63 | + |
| 64 | +export function PageHeader({ |
| 65 | + title, |
| 66 | + level = "secondary", |
| 67 | + onBack, |
| 68 | + backLabel = "Back", |
| 69 | + backAriaLabel, |
| 70 | + kicker, |
| 71 | + rightSlot, |
| 72 | + titleAs = "h2", |
| 73 | + className, |
| 74 | +}: PageHeaderProps) { |
| 75 | + const TitleTag = titleAs; |
| 76 | + |
| 77 | + return ( |
| 78 | + <div className={clsx("page-header", `page-header--${level}`, className)}> |
| 79 | + <div className="page-header__leading"> |
| 80 | + {onBack ? ( |
| 81 | + <button |
| 82 | + type="button" |
| 83 | + className="page-header__back" |
| 84 | + onClick={onBack} |
| 85 | + aria-label={backAriaLabel ?? backLabel} |
| 86 | + > |
| 87 | + <ArrowLeft size={16} /> |
| 88 | + <span>{backLabel}</span> |
| 89 | + </button> |
| 90 | + ) : null} |
| 91 | + <div className="page-header__copy"> |
| 92 | + {kicker ? <div className="page-header__kicker">{kicker}</div> : null} |
| 93 | + <TitleTag className="page-header__title">{title}</TitleTag> |
| 94 | + </div> |
| 95 | + </div> |
| 96 | + {rightSlot ? <div className="page-header__actions">{rightSlot}</div> : null} |
| 97 | + </div> |
| 98 | + ); |
| 99 | +} |
| 100 | +``` |
| 101 | + |
| 102 | +- [ ] **Step 4: Run the test to verify it passes** |
| 103 | + |
| 104 | +Run: `pnpm vitest packages/web/src/features/shared/components/page-header.test.tsx -v` |
| 105 | +Expected: PASS. |
| 106 | + |
| 107 | +- [ ] **Step 5: Commit** |
| 108 | + |
| 109 | +```bash |
| 110 | +git add packages/web/src/features/shared/components/page-header.tsx packages/web/src/features/shared/components/page-header.test.tsx packages/web/src/features/shared/components/mobile-page-header.tsx packages/web/src/features/shared/components/mobile-page-header.test.tsx packages/web/src/styles/components.css |
| 111 | +git commit -m "feat(web): add header levels to shared page header" |
| 112 | +``` |
| 113 | + |
| 114 | +### Task 2: Add a dedicated PanelHeader primitive and migrate dense panel chrome |
| 115 | + |
| 116 | +**Files:** |
| 117 | +- Create: `packages/web/src/features/shared/components/panel-header.tsx` |
| 118 | +- Create: `packages/web/src/features/shared/components/panel-header.test.tsx` |
| 119 | +- Modify: `packages/web/src/features/agent-panes/views/shared/session-card.tsx` |
| 120 | +- Modify: `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` |
| 121 | +- Modify: `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` |
| 122 | +- Modify: `packages/web/src/styles/components.css` |
| 123 | + |
| 124 | +- [ ] **Step 1: Write the failing test** |
| 125 | + |
| 126 | +```tsx |
| 127 | +import { render, screen, within } from "@testing-library/react"; |
| 128 | +import { describe, expect, it, vi } from "vitest"; |
| 129 | +import { PanelHeader } from "./panel-header"; |
| 130 | + |
| 131 | +describe("PanelHeader", () => { |
| 132 | + it("renders title, meta, and right-side actions in a dense panel layout", () => { |
| 133 | + render( |
| 134 | + <PanelHeader |
| 135 | + title="Files" |
| 136 | + meta="3 items" |
| 137 | + actions={<button type="button">New File</button>} |
| 138 | + /> |
| 139 | + ); |
| 140 | + |
| 141 | + expect(screen.getByText("Files")).toBeInTheDocument(); |
| 142 | + expect(screen.getByText("3 items")).toBeInTheDocument(); |
| 143 | + expect(within(document.querySelector(".panel-header") as HTMLElement).getByRole("button")).toBeInTheDocument(); |
| 144 | + }); |
| 145 | +}); |
| 146 | +``` |
| 147 | + |
| 148 | +- [ ] **Step 2: Run the test to verify it fails** |
| 149 | + |
| 150 | +Run: `pnpm vitest packages/web/src/features/shared/components/panel-header.test.tsx -v` |
| 151 | +Expected: FAIL because the component does not exist yet. |
| 152 | + |
| 153 | +- [ ] **Step 3: Write the minimal implementation** |
| 154 | + |
| 155 | +```tsx |
| 156 | +export interface PanelHeaderProps { |
| 157 | + title: string; |
| 158 | + meta?: ReactNode; |
| 159 | + status?: ReactNode; |
| 160 | + actions?: ReactNode; |
| 161 | + className?: string; |
| 162 | +} |
| 163 | + |
| 164 | +export function PanelHeader({ title, meta, status, actions, className }: PanelHeaderProps) { |
| 165 | + return ( |
| 166 | + <div className={clsx("panel-header", className)}> |
| 167 | + <div className="panel-header__copy"> |
| 168 | + <div className="panel-header__title">{title}</div> |
| 169 | + {meta || status ? <div className="panel-header__meta">{meta ?? status}</div> : null} |
| 170 | + </div> |
| 171 | + {actions ? <div className="panel-header__actions">{actions}</div> : null} |
| 172 | + </div> |
| 173 | + ); |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +- [ ] **Step 4: Run the test to verify it passes** |
| 178 | + |
| 179 | +Run: `pnpm vitest packages/web/src/features/shared/components/panel-header.test.tsx -v` |
| 180 | +Expected: PASS. |
| 181 | + |
| 182 | +- [ ] **Step 5: Migrate panel chrome to the new primitive** |
| 183 | + |
| 184 | +Replace the hand-written headers in: |
| 185 | + |
| 186 | +- `packages/web/src/features/agent-panes/views/shared/session-card.tsx` |
| 187 | +- `packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx` |
| 188 | +- `packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx` |
| 189 | + |
| 190 | +Keep the existing surface behavior, but move header layout and sizing into `PanelHeader` and shared CSS classes. |
| 191 | + |
| 192 | +- [ ] **Step 6: Commit** |
| 193 | + |
| 194 | +```bash |
| 195 | +git add packages/web/src/features/shared/components/panel-header.tsx packages/web/src/features/shared/components/panel-header.test.tsx packages/web/src/features/agent-panes/views/shared/session-card.tsx packages/web/src/features/workspace/views/shared/git-diff-viewer.tsx packages/web/src/features/workspace/views/desktop/workspace-desktop-view.tsx packages/web/src/styles/components.css |
| 196 | +git commit -m "feat(web): introduce shared panel header" |
| 197 | +``` |
| 198 | + |
| 199 | +### Task 3: Make modal headers the canonical DialogHeader |
| 200 | + |
| 201 | +**Files:** |
| 202 | +- Modify: `packages/web/src/components/ui/modal/index.tsx` |
| 203 | +- Modify: `packages/web/src/components/ui/modal/index.module.css` |
| 204 | +- Modify: `packages/web/src/components/ui/modal/index.test.tsx` |
| 205 | +- Modify: `packages/web/src/components/ui/index.ts` |
| 206 | +- Modify: `packages/web/src/components/ui/modal/README.md` |
| 207 | +- Modify: `packages/web/src/features/supervisor/views/shared/objective-dialog.tsx` |
| 208 | + |
| 209 | +- [ ] **Step 1: Write the failing test** |
| 210 | + |
| 211 | +```tsx |
| 212 | +import { render, screen } from "@testing-library/react"; |
| 213 | +import { describe, expect, it } from "vitest"; |
| 214 | +import { DialogHeader, ModalTitle } from "."; // new public alias |
| 215 | + |
| 216 | +describe("Modal dialog header", () => { |
| 217 | + it("exports a dialog header alias with the same modal chrome", () => { |
| 218 | + render( |
| 219 | + <DialogHeader> |
| 220 | + <ModalTitle>Workspace details</ModalTitle> |
| 221 | + </DialogHeader> |
| 222 | + ); |
| 223 | + |
| 224 | + expect(screen.getByText("Workspace details")).toBeInTheDocument(); |
| 225 | + expect(document.querySelector(".modal-header")).toBeTruthy(); |
| 226 | + }); |
| 227 | +}); |
| 228 | +``` |
| 229 | + |
| 230 | +- [ ] **Step 2: Run the test to verify it fails** |
| 231 | + |
| 232 | +Run: `pnpm vitest packages/web/src/components/ui/modal/index.test.tsx -v` |
| 233 | +Expected: FAIL because `DialogHeader` is not exported yet. |
| 234 | + |
| 235 | +- [ ] **Step 3: Write the minimal implementation** |
| 236 | + |
| 237 | +```tsx |
| 238 | +export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) { |
| 239 | + return <div {...props} className={clsx(styles.header, "modal-header", className)} />; |
| 240 | +} |
| 241 | + |
| 242 | +export { DialogHeader as ModalHeader }; |
| 243 | +``` |
| 244 | + |
| 245 | +- [ ] **Step 4: Run the test to verify it passes** |
| 246 | + |
| 247 | +Run: `pnpm vitest packages/web/src/components/ui/modal/index.test.tsx -v` |
| 248 | +Expected: PASS. |
| 249 | + |
| 250 | +- [ ] **Step 5: Migrate supervisor dialog to the dialog header contract** |
| 251 | + |
| 252 | +Update `packages/web/src/features/supervisor/views/shared/objective-dialog.tsx` so it uses the canonical dialog header structure for icon, title, description, and close action instead of an ad hoc header block. |
| 253 | + |
| 254 | +- [ ] **Step 6: Update exports and docs** |
| 255 | + |
| 256 | +Expose `DialogHeader` from `packages/web/src/components/ui/index.ts` and describe the preserved compatibility class names in `packages/web/src/components/ui/modal/README.md`. |
| 257 | + |
| 258 | +- [ ] **Step 7: Commit** |
| 259 | + |
| 260 | +```bash |
| 261 | +git add packages/web/src/components/ui/modal/index.tsx packages/web/src/components/ui/modal/index.module.css packages/web/src/components/ui/modal/index.test.tsx packages/web/src/components/ui/index.ts packages/web/src/components/ui/modal/README.md packages/web/src/features/supervisor/views/shared/objective-dialog.tsx |
| 262 | +git commit -m "feat(web): add dialog header alias" |
| 263 | +``` |
| 264 | + |
| 265 | +### Task 4: Migrate settings and lock the header contract with coverage |
| 266 | + |
| 267 | +**Files:** |
| 268 | +- Modify: `packages/web/src/features/settings/components/settings-page.tsx` |
| 269 | +- Modify: `packages/web/src/features/shared/components/page-header.test.tsx` |
| 270 | +- Modify: `packages/web/src/features/shared/components/mobile-page-header.test.tsx` |
| 271 | +- Modify: `packages/web/src/features/agent-panes/components/session-card.test.tsx` |
| 272 | +- Modify: `packages/web/src/components/ui/modal/index.test.tsx` |
| 273 | +- Modify: `packages/web/src/styles/components.theme.test.ts` |
| 274 | + |
| 275 | +- [ ] **Step 1: Write the failing test** |
| 276 | + |
| 277 | +```tsx |
| 278 | +import { render, screen } from "@testing-library/react"; |
| 279 | +import { describe, expect, it } from "vitest"; |
| 280 | +import { SettingsPage } from "./settings-page"; |
| 281 | + |
| 282 | +describe("SettingsPage", () => { |
| 283 | + it("renders the page header through the shared PageHeader contract on desktop", () => { |
| 284 | + render( |
| 285 | + <SettingsPage /> |
| 286 | + ); |
| 287 | + |
| 288 | + expect(document.querySelector(".settings-header")).toBeTruthy(); |
| 289 | + expect(document.querySelector(".page-header")).toBeTruthy(); |
| 290 | + }); |
| 291 | +}); |
| 292 | +``` |
| 293 | + |
| 294 | +- [ ] **Step 2: Run the test to verify it fails** |
| 295 | + |
| 296 | +Run: `pnpm vitest packages/web/src/features/settings/components/settings-page.tsx -v` |
| 297 | +Expected: FAIL until the desktop settings header is migrated to `PageHeader`. |
| 298 | + |
| 299 | +- [ ] **Step 3: Write the minimal implementation** |
| 300 | + |
| 301 | +```tsx |
| 302 | +<header className="settings-header"> |
| 303 | + <PageHeader |
| 304 | + level={isMobile ? "secondary" : "secondary"} |
| 305 | + title={headerTitle} |
| 306 | + titleAs="h1" |
| 307 | + onBack={handleBack} |
| 308 | + backLabel={t("action.back")} |
| 309 | + /> |
| 310 | +</header> |
| 311 | +``` |
| 312 | + |
| 313 | +- [ ] **Step 4: Update coverage for the contract** |
| 314 | + |
| 315 | +Add or adjust tests so the project now verifies: |
| 316 | + |
| 317 | +- `PageHeader` primary / secondary rendering |
| 318 | +- `PanelHeader` dense layout expectations |
| 319 | +- `DialogHeader` export / modal compatibility |
| 320 | +- mobile page header still suppresses kicker on narrow layouts |
| 321 | + |
| 322 | +- [ ] **Step 5: Run the targeted test set** |
| 323 | + |
| 324 | +Run: |
| 325 | + |
| 326 | +```bash |
| 327 | +pnpm vitest packages/web/src/features/shared/components/page-header.test.tsx packages/web/src/features/shared/components/mobile-page-header.test.tsx packages/web/src/features/shared/components/panel-header.test.tsx packages/web/src/components/ui/modal/index.test.tsx packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/agent-panes/components/session-card.test.tsx -v |
| 328 | +``` |
| 329 | + |
| 330 | +Expected: PASS. |
| 331 | + |
| 332 | +- [ ] **Step 6: Commit** |
| 333 | + |
| 334 | +```bash |
| 335 | +git add packages/web/src/features/settings/components/settings-page.tsx packages/web/src/features/shared/components/page-header.test.tsx packages/web/src/features/shared/components/mobile-page-header.test.tsx packages/web/src/features/agent-panes/components/session-card.test.tsx packages/web/src/components/ui/modal/index.test.tsx packages/web/src/styles/components.theme.test.ts |
| 336 | +git commit -m "feat(web): migrate settings header to shared contract" |
| 337 | +``` |
| 338 | + |
| 339 | +--- |
| 340 | + |
| 341 | +**Execution notes** |
| 342 | + |
| 343 | +- Keep all new public header usage inside the shared primitives. |
| 344 | +- Do not introduce any new `*-header` business-only component during implementation. |
| 345 | +- Prefer compatibility aliases during migration, but remove ad hoc layout logic as each surface is moved. |
0 commit comments