feat(dashboard): finish ViewHeader rollout + normalize content padding

- Migrate Insights, Dev Server, Todos, Pull Requests to the shared ViewHeader.
- Fix doubled top padding (ViewHeader already pads top) across Documents/Agents/Mailbox/Missions content areas.
- Nudge embedded-view headers to the shared spec (CommandCenter icon 20; WorkflowNodeEditor icon+1.125rem title+space-lg; Settings embedded icon). ChatView intentionally headerless.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 03:10:41 -07:00
parent fe98a2a699
commit 533d10095d
18 changed files with 151 additions and 195 deletions

View File

@@ -278,11 +278,12 @@ The base grid keeps a token-sized handle column as the no-JS fallback, while the
background: var(--surface);
}
/* FNXC:AgentsView 2026-06-22-01:00: ViewHeader supplies the top padding, so the scrollable body drops its top inset to avoid doubling the gap under the header (keeps horizontal + bottom padding). */
.agents-view-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: calc(var(--space-lg) + var(--space-xs));
padding: 0 calc(var(--space-lg) + var(--space-xs)) calc(var(--space-lg) + var(--space-xs));
}

View File

@@ -1,10 +1,14 @@
/* === DevServerView === */
/*
FNXC:DevServer 2026-06-22-01:00:
Header migrated to the shared ViewHeader (.view-header), which supplies the --space-lg top/side and --space-md bottom padding. The view no longer adds its own top padding (was causing a doubled gap under the header); only the side and bottom padding remain.
*/
.dev-server-view {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding: var(--space-lg);
padding: 0 var(--space-lg) var(--space-lg);
min-height: 0;
height: 100%;
overflow-y: auto;
@@ -13,29 +17,15 @@
}
/*
FNXC:DevServer 2026-06-22-00:00:
The Dev Server view header must read like other embedded views (Command Center cc-header): a plain title row with actions, not a bordered card. Drop the card border/background/padding so the heading sits flush with the view padding; the title font already matches the shared 1.125rem embedded-title size.
FNXC:DevServer 2026-06-22-01:00:
.dev-server-header-title now wraps just the status badge inside ViewHeader's actions slot; the mobile flex-wrap rule keeps it from overflowing on narrow widths.
*/
.dev-server-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
}
.dev-server-header-title {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.dev-server-header-title h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--text);
}
.dev-server-header-actions {
display: flex;
align-items: center;
@@ -542,7 +532,7 @@ The Dev Server view header must read like other embedded views (Command Center c
grid-template-rows: auto auto 1fr;
}
.dev-server-header {
.dev-server-view > .view-header {
grid-column: 1 / -1;
}
@@ -572,12 +562,7 @@ The Dev Server view header must read like other embedded views (Command Center c
.dev-server-view {
display: flex;
flex-direction: column;
padding: var(--space-md);
}
.dev-server-header {
flex-direction: column;
align-items: flex-start;
padding: 0 var(--space-md) var(--space-md);
}
.dev-server-header-title {

View File

@@ -11,6 +11,7 @@ import type { ToastType } from "../hooks/useToast";
import { DevServerLogViewer } from "./DevServerLogViewer";
import { PreviewIframe } from "./PreviewIframe";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
import { ViewHeader } from "./ViewHeader";
interface DevServerViewProps {
addToast: (msg: string, type?: ToastType) => void;
@@ -365,50 +366,58 @@ export function DevServerView({ addToast, projectId }: DevServerViewProps) {
return (
<div className="dev-server-view" data-testid="dev-server-view">
<section className="dev-server-header" aria-label={t("devserver.controlsHeaderLabel", "Dev server controls header")}>
<div className="dev-server-header-title">
<Monitor size={16} />
<h2>{t("devserver.title", "Dev Server")}</h2>
<span
className={`dev-server-status-badge ${statusBadge.className}`}
data-testid="dev-server-status-badge"
>
{statusBadge.label}
</span>
</div>
<div className="dev-server-header-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleStart}
disabled={startDisabled}
data-testid="dev-server-start-button"
>
<Play size={14} />
<span>{actionInFlight === "start" ? t("devserver.starting", "Starting...") : t("devserver.start", "Start")}</span>
</button>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleStop}
disabled={stopDisabled}
data-testid="dev-server-stop-button"
>
<Square size={14} />
<span>{actionInFlight === "stop" ? t("devserver.stopping", "Stopping...") : t("devserver.stop", "Stop")}</span>
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleRestart}
disabled={restartDisabled}
data-testid="dev-server-restart-button"
>
<RotateCw size={14} />
<span>{actionInFlight === "restart" ? t("devserver.restarting", "Restarting...") : t("devserver.restart", "Restart")}</span>
</button>
</div>
</section>
{/*
FNXC:DevServer 2026-06-22-01:00:
Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding.
*/}
<ViewHeader
icon={Monitor}
title={t("devserver.title", "Dev Server")}
actions={(
<>
<span className="dev-server-header-title">
<span
className={`dev-server-status-badge ${statusBadge.className}`}
data-testid="dev-server-status-badge"
>
{statusBadge.label}
</span>
</span>
<div className="dev-server-header-actions">
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleStart}
disabled={startDisabled}
data-testid="dev-server-start-button"
>
<Play size={14} />
<span>{actionInFlight === "start" ? t("devserver.starting", "Starting...") : t("devserver.start", "Start")}</span>
</button>
<button
type="button"
className="btn btn-danger btn-sm"
onClick={handleStop}
disabled={stopDisabled}
data-testid="dev-server-stop-button"
>
<Square size={14} />
<span>{actionInFlight === "stop" ? t("devserver.stopping", "Stopping...") : t("devserver.stop", "Stop")}</span>
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleRestart}
disabled={restartDisabled}
data-testid="dev-server-restart-button"
>
<RotateCw size={14} />
<span>{actionInFlight === "restart" ? t("devserver.restarting", "Restarting...") : t("devserver.restart", "Restart")}</span>
</button>
</div>
</>
)}
/>
<section className="dev-server-panel dev-server-config" aria-label={t("devserver.configurationLabel", "Dev server configuration")}>
<div className="dev-server-section-header">

View File

@@ -149,11 +149,12 @@ The header row now comes from the shared .view-header (which supplies the --spac
box-shadow: var(--focus-ring-strong);
}
/* FNXC:DocumentsView 2026-06-22-01:00: ViewHeader supplies the top padding, so the scrollable content body drops its top inset to avoid doubling the gap under the header (keeps horizontal + bottom padding). */
.documents-view-content {
flex: 1;
min-height: 0;
overflow: auto;
padding: var(--space-lg);
padding: 0 var(--space-lg) var(--space-lg);
}
.documents-view-loading,

View File

@@ -8,48 +8,15 @@
}
/*
FNXC:Insights 2026-06-22-00:00:
The header title and action buttons must never overlap: allow the row to wrap so the actions drop to a new line when there is not enough horizontal room. The gap keeps spacing between the wrapped rows.
FNXC:Insights 2026-06-22-01:00:
Header migrated to the shared ViewHeader component (.view-header). The old .insights-view-header / .insights-view-title / .insights-view-actions rules were removed; the count badge and action toggles still render inside ViewHeader's actions slot, so their styling rules are retained below.
*/
.insights-view-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-lg);
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.insights-view-title {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.insights-view-title h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
display: flex;
align-items: center;
gap: var(--space-sm);
}
.insights-view-count {
font-size: 0.8125rem;
color: var(--text-muted);
font-weight: normal;
}
.insights-view-actions {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* Model configuration row — collapsible, below the action bar */
.insights-model-config {
display: flex;
@@ -493,31 +460,6 @@ The header title and action buttons must never overlap: allow the row to wrap so
/* Mobile responsive: stack panes vertically; sidebar becomes a horizontal scroller */
@media (max-width: 768px) {
.insights-view-header {
flex-wrap: nowrap;
gap: var(--space-sm);
padding: var(--space-md);
}
.insights-view-title {
min-width: 0;
flex: 1 1 auto;
}
.insights-view-title h2 {
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insights-view-actions {
flex-shrink: 0;
justify-content: flex-end;
flex-wrap: nowrap;
gap: var(--space-xs);
}
.insights-body {
flex-direction: column;
}
@@ -591,6 +533,7 @@ The header title and action buttons must never overlap: allow the row to wrap so
height: calc(var(--space-2xl) + var(--space-sm));
}
.view-header__actions .insights-view-actions-btn,
.insights-view-actions .btn {
min-height: calc(var(--space-2xl) + var(--space-xs));
}
@@ -633,19 +576,6 @@ Insights tablets at 769px–1024px were inheriting the desktop fixed category si
overflow: hidden;
}
.insights-view-header {
min-inline-size: 0;
}
.insights-view-title {
flex: 1 1 auto;
min-inline-size: 0;
}
.insights-view-actions {
flex-shrink: 0;
}
.insights-body {
flex-direction: column;
inline-size: 100%;

View File

@@ -27,6 +27,7 @@ import {
Activity,
} from "lucide-react";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ViewHeader } from "./ViewHeader";
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
import { useInsights, type InsightSection } from "../hooks/useInsights";
import { BACKLOG_HEALTH_TITLE_PREFIXES, isBacklogHealthInsight } from "./backlog-health-filter";
@@ -480,16 +481,16 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
return (
<div className="insights-view" data-testid="insights-view">
<div className="insights-view-header">
<div className="insights-view-title">
<h2>
<Sparkles size={20} />
{t("insights.title", "Insights")}
</h2>
<span className="insights-view-count">{totalCount} {t("common.total", "total")}</span>
</div>
<div className="insights-view-actions">
{/*
FNXC:Insights 2026-06-22-01:00:
Migrated to the shared ViewHeader for consistency with other main-content views. The insight count and action buttons live in the actions slot; ViewHeader already provides the --space-lg side/top padding and --space-md bottom gap, so the view body must not repeat the top padding.
*/}
<ViewHeader
icon={Sparkles}
title={t("insights.title", "Insights")}
actions={(
<>
<span className="insights-view-count">{totalCount} {t("common.total", "total")}</span>
{backlogHealthCount > 0 && (
<button
className={`btn btn-sm insights-backlog-health-toggle${backlogHealthOnly ? " btn-icon--active" : ""}`}
@@ -564,8 +565,9 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
</>
)}
</button>
</div>
</div>
</>
)}
/>
{showModelConfig && (
<div className="insights-model-config" data-testid="model-config">

View File

@@ -652,12 +652,13 @@
padding-top: var(--space-sm);
}
/* FNXC:MailboxView 2026-06-22-01:00: ViewHeader supplies the top padding, so the standalone-view scroll body drops its top inset to avoid doubling the gap under the header (keeps horizontal + bottom padding). */
.mailbox-view .mailbox-content {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: var(--space-xl);
padding: 0 var(--space-xl) var(--space-xl);
max-height: none;
}

View File

@@ -139,13 +139,14 @@ Title metric matches the shared ViewHeader (1.125rem) so the Missions header rea
}
/* ── Body ── */
/* FNXC:MissionManager 2026-06-22-01:00: The aligned .mission-manager__header supplies the top padding, so the scroll body drops its top inset to avoid doubling the gap under the header (keeps horizontal + bottom padding). */
.mission-manager__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
padding: var(--space-lg);
padding: 0 var(--space-lg) var(--space-lg);
-webkit-overflow-scrolling: touch;
}

View File

@@ -1,3 +1,7 @@
/*
FNXC:PullRequests 2026-06-22-01:00:
The view now renders the shared ViewHeader at the top, which supplies the --space-lg top/side padding. The view drops its own top padding so the gap under the header is just ViewHeader's --space-md bottom; side and bottom padding remain.
*/
.pr-view {
display: flex;
flex-direction: column;
@@ -6,7 +10,7 @@
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--space-lg);
padding: 0 var(--space-lg) var(--space-lg);
color: var(--text);
}

View File

@@ -13,6 +13,7 @@ import {
MessageSquare,
} from "lucide-react";
import { api } from "../api";
import { ViewHeader } from "./ViewHeader";
import "./PullRequestView.css";
// Mirrors the route's serialized entity (register-pull-requests-routes.ts).
@@ -175,10 +176,17 @@ export function PullRequestView(props: PullRequestViewProps) {
const { state, summary } = detail;
/*
FNXC:PullRequests 2026-06-22-01:00:
Added the shared ViewHeader (GitPullRequest icon, matching the left-sidebar nav) at the top of every populated PR state so the view reads consistently with other main-content views. The PR-specific identity row (repo/number/branch/state) stays below it. ViewHeader supplies the standard --space-lg top/side padding; the view body must not repeat the top padding.
*/
const viewHeader = <ViewHeader icon={GitPullRequest} title={t("pr.view.title", "Pull Requests")} />;
// ── creating ───────────────────────────────────────────────────────────────
if (state === "creating") {
return (
<div className="pr-view" data-testid="pr-view" data-state="creating">
{viewHeader}
<PrIdentityHeader detail={detail} />
<div className="pr-placeholder" data-testid="pr-creating">
<Clock size={16} /> {t("pr.view.creating", "Creating PR…")}
@@ -191,6 +199,7 @@ export function PullRequestView(props: PullRequestViewProps) {
if (state === "failed") {
return (
<div className="pr-view" data-testid="pr-view" data-state="failed">
{viewHeader}
<PrIdentityHeader detail={detail} />
<div className="pr-error-reason" data-testid="pr-failed">
<AlertTriangle size={16} className="pr-icon-failure" />
@@ -216,6 +225,7 @@ export function PullRequestView(props: PullRequestViewProps) {
if (detail.unverified) {
return (
<div className="pr-view" data-testid="pr-view" data-state="unverified">
{viewHeader}
<PrIdentityHeader detail={detail} />
<div className="pr-notice pr-notice--unverified" data-testid="pr-unverified">
<Clock size={16} /> {t("pr.view.verifyingGithub", "Verifying with GitHub…")}
@@ -240,6 +250,7 @@ export function PullRequestView(props: PullRequestViewProps) {
return (
<div className="pr-view" data-testid="pr-view" data-state={state}>
{viewHeader}
<PrIdentityHeader detail={detail} />
{/* responding banner */}

View File

@@ -145,6 +145,9 @@ The embedded title reads like other embedded-view titles (Planning modal-header-
}
.settings-modal--embedded .modal-header--embedded .settings-modal-heading h3 {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 1.125rem;
letter-spacing: normal;
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useRef, type CSSProperties, type MouseEvent } from "react";
import { Globe, Folder, RefreshCw, Star, HelpCircle } from "lucide-react";
import { Globe, Folder, RefreshCw, Star, HelpCircle, Settings as SettingsIcon } from "lucide-react";
import {
getErrorMessage,
normalizeMergeIntegrationWorktreeMode,
@@ -2920,8 +2920,12 @@ export function SettingsModal({
style={isEmbedded ? undefined : keyboardStyle}
>
<div className={isEmbedded ? "modal-header modal-header--embedded" : "modal-header"}>
{/* FNXC:Settings 2026-06-22-01:00: Embedded title gains a Settings icon (size 20, matching the sidebar nav and shared ViewHeader) so the embedded settings panel reads consistently with other main-content destinations; title is already 1.125rem. */}
<div className="settings-modal-heading">
<h3>{t("settings.title", "Settings")}</h3>
<h3>
{isEmbedded && <SettingsIcon size={20} aria-hidden="true" />}
<span>{t("settings.title", "Settings")}</span>
</h3>
</div>
<div className="settings-header-actions">
<a

View File

@@ -13,7 +13,11 @@ FN-6829 mounts Todos as a flex child of .project-content like GoalsView; grow, z
min-width: 0;
width: 100%;
overflow: hidden;
padding: var(--space-lg);
/*
FNXC:TodosStyling 2026-06-22-01:00:
Header migrated to the shared ViewHeader, which supplies the --space-lg top/side padding. The view drops its own top padding so the gap under the header is only ViewHeader's --space-md bottom; side and bottom padding remain.
*/
padding: 0 var(--space-lg) var(--space-lg);
/*
FNXC:TodosStyling 2026-06-22-00:00:
TodoView renders both in the wide main area and inside the narrow right dock (no width prop). Make it a query container so the layout switch is driven by the actual rendered width, not a viewport media query or a prop. Below the container breakpoint the two-panel split collapses into a single-panel navigation stack (see `@container todo-view (max-width: 520px)`).
@@ -30,26 +34,11 @@ The narrow-stack Back button is hidden by default (wide two-panel layout shows b
display: none;
}
.todo-view-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-md);
}
.todo-view-title-group {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.todo-view-title-group h2 {
margin: 0;
color: var(--text);
font-size: calc(var(--space-lg) + var(--space-xs));
}
.todo-view-title-group p {
/*
FNXC:TodosStyling 2026-06-22-01:00:
The descriptive subtitle renders inside ViewHeader's actions slot; mute it so it reads as secondary text alongside the title.
*/
.todo-view-subtitle {
margin: 0;
color: var(--text-muted);
}
@@ -504,7 +493,7 @@ NARROW container (right dock): collapse the side-by-side split into a single-pan
@media (max-width: 768px) {
.todo-view {
padding: var(--space-md);
padding: 0 var(--space-md) var(--space-md);
min-height: 0;
}

View File

@@ -11,10 +11,12 @@ import {
ChevronLeft,
Loader2,
ListChecks,
CheckSquare,
Bot,
PlusCircle,
Lightbulb,
} from "lucide-react";
import { ViewHeader } from "./ViewHeader";
import { getErrorMessage, type Task, type TaskCreateInput, type TodoItem, type TodoList } from "@fusion/core";
import { createTask, fetchAgents } from "../api";
import type { Agent } from "../api";
@@ -316,16 +318,18 @@ export function TodoView({
}
}, [projectId, addToast, agents, onTaskCreated, t]);
/*
FNXC:Todos 2026-06-22-01:00:
Migrated to the shared ViewHeader (CheckSquare icon, matching the left-sidebar nav) so Todos reads consistently with the other main-content views. The descriptive subtitle moves into the actions slot so it stays visible while the icon + 1.125rem title come from ViewHeader. The header sits above the two-pane/stack layout (flex-shrink:0); the layout owns its own scroll.
*/
const header = (
<header className="todo-view-header">
<div className="todo-view-title-group">
<ListChecks aria-hidden="true" />
<div>
<h2>{t("todo.todos", "Todos")}</h2>
<p>{t("todo.manageDescription", "Manage reusable todo lists for your project.")}</p>
</div>
</div>
</header>
<ViewHeader
icon={CheckSquare}
title={t("todo.todos", "Todos")}
actions={(
<p className="todo-view-subtitle">{t("todo.manageDescription", "Manage reusable todo lists for your project.")}</p>
)}
/>
);
if (loading) {

View File

@@ -47,17 +47,22 @@ embedded view.
--wf-editor-touch-target: calc(var(--space-xl) + var(--space-lg) + var(--space-xs));
}
/* FNXC:WorkflowEditorEmbedding 2026-06-22-01:00: Align the embedded workflows header to the shared ViewHeader metric — --space-lg padding, an icon + 1.125rem title — for cross-view consistency. */
.wf-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
padding: var(--space-lg);
border-bottom: 1px solid var(--border);
}
.wf-editor-header h2 {
display: flex;
align-items: center;
gap: var(--space-sm);
margin: 0;
font-size: 1rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text);
}

View File

@@ -2479,7 +2479,11 @@ function InnerEditor({
}}
>
<header className="wf-editor-header">
<h2>{t("workflows.title", "Workflows")}</h2>
{/* FNXC:WorkflowEditorEmbedding 2026-06-22-01:00: Title row aligned to the shared ViewHeader/Command Center metric — a Workflow icon (size 20) + 1.125rem title — so the embedded workflows view reads consistently with other main-content destinations. */}
<h2>
<Workflow size={20} aria-hidden="true" />
<span>{t("workflows.title", "Workflows")}</span>
</h2>
{/* FNXC:WorkflowEditorEmbedding 2026-06-22-00:00: embedded views keep a
Command Center-style header title but drop the modal X close button. */}
{!isEmbedded ? (

View File

@@ -32,6 +32,7 @@ vi.mock("lucide-react", () => ({
ChevronLeft: () => <span data-testid="icon-chevron-left" />,
Loader2: () => <span data-testid="icon-loader" />,
ListChecks: () => <span data-testid="icon-list-checks" />,
CheckSquare: () => <span data-testid="icon-check-square" />,
Bot: () => <span data-testid="icon-bot" />,
PlusCircle: () => <span data-testid="icon-plus-circle" />,
Lightbulb: () => <span data-testid="icon-lightbulb" />,

View File

@@ -562,8 +562,9 @@ export function CommandCenter({
return (
<section className="command-center" data-testid="command-center">
<header className="cc-header">
{/* FNXC:CommandCenter 2026-06-22-01:00: Icon size aligned to 20 to match the shared ViewHeader (cc-header is the model for ViewHeader; title is already 1.125rem with --space-lg padding). */}
<h2 className="cc-title">
<Gauge size={18} />
<Gauge size={20} />
{t("commandCenter.heading", "Command Center")}
</h2>
<DateRangePicker value={range} onChange={setRange} />