feat(dashboard): list-header one-row, file pop-out opens current file, taller overview buttons; fix redesign test suite

- List view header: count + Bulk Edit + View options + New Task on one compact btn-sm row; view-options shrunk; count compacted/secondary.
- File browser pop-out now opens the file you're currently viewing — current path shared via scoped storage (kb-dashboard-dock-files-current) with cross-instance live-sync.
- Dashboard Overview: View Board / View Agents are now btn btn-secondary (taller, centered) to match the Stop AI Engine button.
- Test suite: fix ~40 stale assertions from the redesign (App nav moved to LeftSidebarNav, Import Tasks embedded, removed Todos in-view header, SkillsView shared ViewHeader, gm embedded keyboard rule, ThemeSelector option drift) — test-only, nothing weakened. Pre-existing board-mobile InlineCreateCard failure left as-is.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 09:47:17 -07:00
parent dfd4baf240
commit 13bd2e473a
12 changed files with 391 additions and 245 deletions

View File

@@ -5,6 +5,7 @@ import { getErrorMessage } from "@fusion/core";
import type { PluginDashboardViewContext } from "../plugins/types";
import { fetchWorkspaceFileContent } from "../api";
import { useWorkspaceFileBrowser } from "../hooks/useWorkspaceFileBrowser";
import { getScopedItem, removeScopedItem, scopedKey, setScopedItem } from "../utils/projectStorage";
import { FileBrowser } from "./FileBrowser";
import { FileEditor } from "./FileEditor";
import "./DockFilesView.css";
@@ -21,6 +22,13 @@ interface DockFilesViewProps {
layout?: "auto" | "two-pane";
}
/*
FNXC:RightDockFiles 2026-06-22-23:30:
The compact dock Files view and the popped-out (expand) Files view are SEPARATE component instances (one renders in the dock body, the other inside RightDockExpandModal). The currently-viewed file lived in each instance's local `selectedFile` state, so popping out always opened with no file selected.
Share the current-file path through scoped localStorage (`kb-dashboard-dock-files-current`, keyed per project via projectStorage). Selecting/clearing a file writes the key; on mount each instance reads it so the expand opens the SAME file the dock was showing. A `storage` listener keeps both instances live-synced when the other tab/instance changes selection.
*/
const DOCK_FILES_CURRENT_KEY = "kb-dashboard-dock-files-current";
/*
FNXC:RightDockFiles 2026-06-22-00:00:
The right-dock Files tool opens a clicked file INLINE inside the dock as a read-only viewer instead of immediately launching the resizable/movable FileBrowserModal.
@@ -40,7 +48,36 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
const { entries, currentPath, setPath, loading, error, refresh } = useWorkspaceFileBrowser("project", true, projectId);
// FNXC:RightDockFiles 2026-06-22-12:00: selected file drives the inline read-only viewer; null returns to the tree.
const [selectedFile, setSelectedFile] = useState<string | null>(null);
// FNXC:RightDockFiles 2026-06-22-23:30: initialize from the shared scoped-storage key so the expand pop-out opens the same file the dock is showing.
const [selectedFile, setSelectedFile] = useState<string | null>(() => getScopedItem(DOCK_FILES_CURRENT_KEY, projectId) || null);
/*
FNXC:RightDockFiles 2026-06-22-23:30:
Persist the current file to the shared scoped key and update local state in one place. Writing the key lets the OTHER instance (dock or expand) pick up the change on its next mount or via the `storage` listener below. An empty/null path clears the key (returns to the tree everywhere).
*/
const selectFile = useCallback((path: string | null) => {
setSelectedFile(path);
if (path) {
setScopedItem(DOCK_FILES_CURRENT_KEY, path, projectId);
} else {
removeScopedItem(DOCK_FILES_CURRENT_KEY, projectId);
}
}, [projectId]);
// FNXC:RightDockFiles 2026-06-22-23:30: re-read the shared key when the project changes, and live-sync from cross-instance `storage` events so dock and expand stay in lockstep.
useEffect(() => {
setSelectedFile(getScopedItem(DOCK_FILES_CURRENT_KEY, projectId) || null);
if (typeof window === "undefined") return;
const watchedKey = scopedKey(DOCK_FILES_CURRENT_KEY, projectId);
const onStorage = (event: StorageEvent) => {
if (event.key !== watchedKey) return;
setSelectedFile(event.newValue || null);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, [projectId]);
const [content, setContent] = useState<string>("");
const [contentLoading, setContentLoading] = useState(false);
const [contentError, setContentError] = useState<string | null>(null);
@@ -76,7 +113,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
};
}, [selectedFile, projectId, t]);
const handleBack = useCallback(() => setSelectedFile(null), []);
const handleBack = useCallback(() => selectFile(null), [selectFile]);
const handlePopOut = useCallback(() => {
if (selectedFile) openFile?.(selectedFile, { workspace: "project" });
}, [openFile, selectedFile]);
@@ -101,7 +138,7 @@ export function DockFilesView({ projectId, openFile, layout = "auto" }: DockFile
<FileBrowser
entries={entries}
currentPath={currentPath}
onSelectFile={(path) => setSelectedFile(path)}
onSelectFile={(path) => selectFile(path)}
onNavigate={setPath}
loading={loading}
error={error}

View File

@@ -26,10 +26,36 @@
background: var(--surface);
}
/*
FNXC:ListView 2026-06-22-23:30:
Single compact header toolbar: count (secondary, left) + action group (right) on one row.
align-items:center keeps the three controls a consistent height; flex-wrap is a last-resort overflow guard, not the default layout.
*/
.list-sidebar-controls__toolbar {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.list-sidebar-controls__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
gap: var(--space-xs);
margin-left: auto;
}
/*
FNXC:ListView 2026-06-22-23:30:
View options was oversized (full-width stacked button). Render it as a compact icon+label btn-sm consistent with its row-mates.
*/
.list-sidebar-controls__actions .list-view-options-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
white-space: nowrap;
}
.list-workflow-control {
@@ -90,6 +116,20 @@
color: var(--text-muted);
}
/*
FNXC:ListView 2026-06-22-23:30:
Compact count for the single header row: smaller, dimmer, and non-dominant so the action group reads as primary. min-width:0 lets it truncate before forcing the row to wrap.
*/
.list-stats--compact {
margin: 0;
min-width: 0;
font-size: calc(var(--space-xs) * 2.5);
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Section expand/collapse controls */
.list-section-controls {
display: flex;

View File

@@ -1975,25 +1975,44 @@ export function ListView({
>
{!isMobile && (
<aside className="list-sidebar-controls" aria-label={t("listView.listControlsLabel", "List controls")}>
{/*
FNXC:ListView 2026-06-22-23:30:
Desktop list header was too tall/spread: a standalone count line, a separate actions row, and a full-width "View options" button stacked vertically.
Collapse them into ONE compact flex toolbar row: secondary count on the left (small, muted, non-dominant), with Bulk Edit + View options + New Task grouped on the right at a consistent btn-sm height.
View options moves into this row as a compact icon+label btn-sm (no longer full-width).
Workflow selector keeps its own row above to avoid crowding the single action row.
Theme tokens only; handlers/data-testids/aria intact.
*/}
<div className="list-sidebar-controls__header">
<p className="list-stats">
{selectedColumn
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
{hiddenCompletedCount > 0 && !selectedColumn && (
<span className="list-stats-hidden"> ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })})</span>
)}
</p>
<div className="list-sidebar-controls__actions">
{renderWorkflowSelector()}
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
</button>
{onNewTask ? (
<button className="btn btn-task-create btn-sm list-new-task-action" onClick={onNewTask}>
{t("listView.newTask", "+ New Task")}
{renderWorkflowSelector()}
<div className="list-sidebar-controls__toolbar">
<p className="list-stats list-stats--compact">
{selectedColumn
? t("listView.statsInColumn", "{{count}} of {{total}} tasks in {{column}}", { count: filteredCount, total: tasks.length, column: getListColumnLabel(selectedColumn) })
: t("listView.stats", "{{count}} of {{total}} tasks", { count: filteredCount, total: tasks.length })}
{hiddenCompletedCount > 0 && !selectedColumn && (
<span className="list-stats-hidden"> ({t("listView.hidden", "{{count}} hidden", { count: hiddenCompletedCount })})</span>
)}
</p>
<div className="list-sidebar-controls__actions">
<button className="btn btn-sm" onClick={toggleBulkEdit} aria-pressed={bulkEditEnabled}>
{bulkEditEnabled ? t("listView.doneEditing", "Done Editing") : t("listView.bulkEdit", "Bulk Edit")}
</button>
) : null}
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
aria-expanded={viewOptionsOpen}
aria-controls="list-view-options-panel"
>
<Columns3 size={14} />
{t("listView.viewOptions", "View options")}
</button>
{onNewTask ? (
<button className="btn btn-task-create btn-sm list-new-task-action" onClick={onNewTask}>
{t("listView.newTask", "+ New Task")}
</button>
) : null}
</div>
</div>
<div className="list-sidebar-summary-chips">
{selectedColumn ? (
@@ -2014,15 +2033,6 @@ export function ListView({
) : null}
</div>
</div>
<button
className="btn btn-sm list-view-options-toggle"
onClick={() => setViewOptionsOpen((prev) => !prev)}
aria-expanded={viewOptionsOpen}
aria-controls="list-view-options-panel"
>
<Columns3 size={14} />
{t("listView.viewOptions", "View options")}
</button>
{viewOptionsOpen && renderViewOptionsPanel("list-view-options-panel")}
{bulkEditEnabled && selectedTaskIds.size > 0 ? renderBulkEditToolbars() : null}
</aside>

View File

@@ -290,13 +290,20 @@ vi.mock("../../components/TaskDetailModal", () => ({
}));
vi.mock("../../components/GitHubImportModal", () => ({
GitHubImportModal: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
// Embedded presentation (sidebar "Import Tasks" destination) drops the modal
// overlay + Cancel button; modal presentation (mobile overflow path) keeps them.
GitHubImportModal: ({ isOpen, onClose, presentation = "modal" }: { isOpen: boolean; onClose: () => void; presentation?: "modal" | "embedded" }) =>
isOpen ? (
<div className="modal-overlay open">
<div
className={presentation === "embedded" ? "github-import-modal github-import-modal--embedded open" : "modal-overlay open"}
data-testid={presentation === "embedded" ? "github-import-view" : undefined}
>
<h2>Import from GitHub</h2>
<button type="button" onClick={onClose}>
Cancel
</button>
{presentation === "embedded" ? null : (
<button type="button" onClick={onClose}>
Cancel
</button>
)}
</div>
) : null,
}));
@@ -1264,6 +1271,14 @@ describe("App chat unread response indicator", () => {
}).events;
};
// FNXC:Navigation 2026-06-22-09:30: With the left sidebar as primary nav, the chat
// unread indicator moved from the header chat button to the Chat sidebar entry's
// status dot (.left-sidebar-nav__dot inside the sidebar-nav-chat button).
const chatUnreadDot = () => {
const chatNav = screen.queryByTestId("sidebar-nav-chat");
return chatNav ? chatNav.querySelector(".left-sidebar-nav__dot.status-dot--pending") : null;
};
it("shows unread indicator when assistant message arrives for any individual session", async () => {
const events = await getChatEvents();
@@ -1276,7 +1291,7 @@ describe("App chat unread response indicator", () => {
});
await waitFor(() => {
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
expect(chatUnreadDot()).not.toBeNull();
});
});
@@ -1291,7 +1306,7 @@ describe("App chat unread response indicator", () => {
);
});
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
expect(chatUnreadDot()).toBeNull();
});
it("shows unread indicator for room assistant replies", async () => {
@@ -1306,7 +1321,7 @@ describe("App chat unread response indicator", () => {
});
await waitFor(() => {
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
expect(chatUnreadDot()).not.toBeNull();
});
});
@@ -1321,7 +1336,7 @@ describe("App chat unread response indicator", () => {
);
});
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
expect(chatUnreadDot()).toBeNull();
});
it("clears unread indicator when returning to chat and does not mark while in chat", async () => {
@@ -1336,13 +1351,13 @@ describe("App chat unread response indicator", () => {
});
await waitFor(() => {
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
expect(chatUnreadDot()).not.toBeNull();
});
fireEvent.click(screen.getByTestId("header-chat-view-btn"));
fireEvent.click(screen.getByTestId("sidebar-nav-chat"));
await waitFor(() => {
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
expect(chatUnreadDot()).toBeNull();
});
await act(async () => {
@@ -1353,7 +1368,7 @@ describe("App chat unread response indicator", () => {
);
});
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
expect(chatUnreadDot()).toBeNull();
});
});
@@ -1737,7 +1752,7 @@ describe("App mission wiring", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Missions view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-missions")).toBeTruthy();
});
});
});
@@ -1757,7 +1772,7 @@ describe("App auto-open Settings on unauthenticated", () => {
});
// Settings modal should NOT be open
expect(screen.queryByText("Settings")).toBeNull();
expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull();
});
it("auto-opens Settings to Authentication tab when all providers are unauthenticated but onboarding IS complete", async () => {
@@ -1803,7 +1818,7 @@ describe("App auto-open Settings on unauthenticated", () => {
// Settings modal should NOT be open
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.queryByText("Settings")).toBeNull();
expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI")).toBeNull();
@@ -1826,7 +1841,7 @@ describe("App auto-open Settings on unauthenticated", () => {
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.queryByText("Settings")).toBeNull();
expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull();
expect(screen.queryByText("Set Up AI")).toBeNull();
});
@@ -1861,7 +1876,7 @@ describe("App auto-open Settings on unauthenticated", () => {
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
// Settings modal should NOT be open
expect(screen.queryByText("Settings")).toBeNull();
expect(screen.queryByRole("heading", { name: "Settings" })).toBeNull();
// Onboarding modal should NOT be open
expect(screen.queryByText("Set Up AI")).toBeNull();
});
@@ -1884,7 +1899,8 @@ describe("App auto-open Settings on unauthenticated", () => {
expect(screen.queryByText("Set Up AI")).toBeNull();
});
// Open settings via the gear icon button
// Open settings via the sidebar Settings entry (header gear is hidden when the
// left sidebar owns desktop Settings); it navigates to the embedded SettingsView.
const settingsButton = screen.getByTitle("Settings");
fireEvent.click(settingsButton);
@@ -1892,8 +1908,11 @@ describe("App auto-open Settings on unauthenticated", () => {
await waitFor(() => expect(fetchSettings.mock.calls.length).toBeGreaterThanOrEqual(2));
await waitFor(() => expect(fetchAuthStatus).toHaveBeenCalled());
// Authentication section content should be visible (providers listed)
expect(screen.getByText("Anthropic")).toBeTruthy();
// Authentication section content should be visible (providers listed).
// The embedded SettingsView is lazy + fetches auth async, so await the provider row.
await waitFor(() => {
expect(screen.getByText("Anthropic")).toBeTruthy();
});
// Click on General to verify General section has Task Prefix
fireEvent.click(screen.getAllByText("General")[0]);
@@ -1964,7 +1983,9 @@ describe("OnboardingResumeCard", () => {
});
describe("App view switching", () => {
it("opens research view from overflow and persists view selection", async () => {
// FNXC:Navigation 2026-06-22-09:30: Research/Evals/Insights/Memory are now left-sidebar
// destinations (sidebar-nav-*), not header More-views overflow items, on desktop.
it("opens research view from the sidebar and persists view selection", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
...defaultSettings,
@@ -1976,12 +1997,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(await screen.findByTestId("view-overflow-research"));
fireEvent.click(await screen.findByTestId("sidebar-nav-research"));
await waitFor(() => {
expect(screen.getByTestId("research-view")).toBeInTheDocument();
@@ -1992,16 +2008,11 @@ describe("App view switching", () => {
localStorage.removeItem(taskViewStorageKey());
});
it("opens evals view from overflow and persists view selection", async () => {
it("opens evals view from the sidebar and persists view selection", async () => {
localStorage.setItem("kb-dashboard-view-mode", "project");
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
fireEvent.click(await screen.findByTestId("view-overflow-evals"));
fireEvent.click(await screen.findByTestId("sidebar-nav-evals"));
await waitFor(() => {
expect(screen.getByTestId("evals-view")).toBeInTheDocument();
@@ -2024,12 +2035,9 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-research")).not.toBeInTheDocument();
// Wait for the sidebar to render, then assert Research is not a destination.
await screen.findByTestId("sidebar-nav-board");
expect(screen.queryByTestId("sidebar-nav-research")).not.toBeInTheDocument();
localStorage.removeItem("kb-dashboard-view-mode");
});
@@ -2122,11 +2130,11 @@ describe("App view switching", () => {
// Wait for the header to render with view toggle
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
// Click to switch to list view
fireEvent.click(screen.getByTitle("List view"));
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
// List view should be rendered (it has a different structure)
await waitFor(() => {
@@ -2145,17 +2153,17 @@ describe("App view switching", () => {
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
// Switch to list view
fireEvent.click(screen.getByTitle("List view"));
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
});
// Switch back to board view
fireEvent.click(screen.getByTitle("Board view"));
fireEvent.click(screen.getByTestId("sidebar-nav-board"));
await waitFor(() => {
expect(document.querySelector(".board")).toBeTruthy();
});
@@ -2171,10 +2179,10 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
fireEvent.click(screen.getByTitle("List view"));
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
await waitFor(() => {
expect(document.querySelector(".list-view")).toBeTruthy();
@@ -2182,9 +2190,10 @@ describe("App view switching", () => {
fireEvent.click(screen.getByText("+ New Task"));
// The NewTaskModal should be visible with its header and description field
// The NewTaskModal should be visible with its header and description field.
// Scope the title to the modal heading; the left sidebar also renders a "New Task" nav label.
await waitFor(() => {
expect(screen.getByText("New Task")).toBeTruthy();
expect(screen.getByRole("heading", { name: "New Task" })).toBeTruthy();
expect(screen.getByPlaceholderText("What needs to be done?")).toBeTruthy();
});
@@ -2201,11 +2210,11 @@ describe("App view switching", () => {
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTitle("List view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
});
// Switch to list view
fireEvent.click(screen.getByTitle("List view"));
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
// Should have saved to localStorage
await waitFor(() => {
@@ -2229,7 +2238,7 @@ describe("App view switching", () => {
});
// List view should be active
expect(screen.getByTitle("List view").className).toContain("active");
expect(screen.getByTestId("sidebar-nav-list").className).toContain("active");
// Cleanup
localStorage.removeItem(taskViewStorageKey());
@@ -2295,7 +2304,7 @@ describe("App view switching", () => {
localStorage.setItem(taskViewStorageKey(), "board");
const second = render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Board view").className).toContain("active");
expect(screen.getByTestId("sidebar-nav-board").className).toContain("active");
});
second.unmount();
@@ -2351,9 +2360,9 @@ describe("App view switching", () => {
// Wait for the header to render with view toggle
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
expect(screen.getByTitle("List view")).toBeTruthy();
expect(screen.getByTitle("Agents view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-list")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-agents")).toBeTruthy();
});
});
@@ -2364,7 +2373,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.queryByTitle("Agents view")).toBeNull();
expect(screen.queryByTestId("sidebar-nav-agents")).toBeNull();
});
localStorage.removeItem("kb-dashboard-view-mode");
@@ -2373,7 +2382,7 @@ describe("App view switching", () => {
it("renders AgentsView when agents view is selected", async () => {
render(<App />);
const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 });
const agentsViewButton = await screen.findByTestId("sidebar-nav-agents", {}, { timeout: 5000 });
// Click to switch to agents view
fireEvent.click(agentsViewButton);
@@ -2393,7 +2402,7 @@ describe("App view switching", () => {
render(<App />);
const agentsViewButton = await screen.findByTitle("Agents view", {}, { timeout: 5000 });
const agentsViewButton = await screen.findByTestId("sidebar-nav-agents", {}, { timeout: 5000 });
fireEvent.click(agentsViewButton);
@@ -2411,7 +2420,7 @@ describe("App view switching", () => {
expect(document.querySelector(".agents-view")).toBeTruthy();
});
expect(screen.getByTitle("Agents view").className).toContain("active");
expect(screen.getByTestId("sidebar-nav-agents").className).toContain("active");
localStorage.removeItem(taskViewStorageKey());
});
@@ -2426,10 +2435,10 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
});
expect(screen.getByTitle("Agents view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-agents")).toBeTruthy();
// Cleanup: restore default mock
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
@@ -2440,19 +2449,12 @@ describe("App view switching", () => {
it("renders InsightsView when insights view is selected", async () => {
render(<App />);
// Wait for the header to render
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy();
});
// Open the overflow menu and click Insights
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
/*
* FNXC:DashboardRouting 2026-06-19-09:04:
* The App-level Insights routing test must wait for the overflow command that users click, not only for the trigger.
* This preserves the lazy-view navigation invariant while avoiding a race with async settings-driven menu commits.
* FNXC:Navigation 2026-06-22-09:30:
* Insights is now a left-sidebar destination (sidebar-nav-insights), not a header
* More-views overflow command. Navigate via the sidebar to exercise lazy-view routing.
*/
fireEvent.click(await screen.findByTestId("view-overflow-insights"));
fireEvent.click(await screen.findByTestId("sidebar-nav-insights"));
// Insights view should be rendered (it has a insights-view container)
expect(await screen.findByTestId("insights-view")).toBeTruthy();
@@ -2508,13 +2510,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
// FNXC:DashboardRouting 2026-06-19-09:15: Insights overflow commands are settings-driven, so task-flow coverage must await the committed command before clicking it.
fireEvent.click(await screen.findByTestId("view-overflow-insights"));
fireEvent.click(await screen.findByTestId("sidebar-nav-insights"));
await waitFor(() => {
expect(screen.getByTestId("create-task-INS-1")).toBeTruthy();
@@ -2543,13 +2539,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTestId("view-toggle-overflow-trigger")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
// FNXC:DashboardRouting 2026-06-19-09:15: Preference persistence exercises the same async overflow command surface as direct Insights navigation.
fireEvent.click(await screen.findByTestId("view-overflow-insights"));
fireEvent.click(await screen.findByTestId("sidebar-nav-insights"));
await waitFor(() => {
expect(localStorage.getItem(taskViewStorageKey())).toBe("insights");
@@ -2565,8 +2555,8 @@ describe("App view switching", () => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
// Overflow trigger should be active when view is insights
expect(screen.getByTestId("view-toggle-overflow-trigger").className).toContain("active");
// Sidebar Insights entry should be active when view is insights
expect(screen.getByTestId("sidebar-nav-insights").className).toContain("active");
localStorage.removeItem(taskViewStorageKey());
});
@@ -2589,8 +2579,8 @@ describe("App view switching", () => {
expect(document.querySelector(".insights-view")).toBeTruthy();
});
// Verify overflow trigger is active
expect(screen.getByTestId("view-toggle-overflow-trigger").className).toContain("active");
// Verify the sidebar Insights entry is active
expect(screen.getByTestId("sidebar-nav-insights").className).toContain("active");
// Cleanup
localStorage.removeItem("kb:proj_a:kb-dashboard-task-view");
@@ -2598,7 +2588,6 @@ describe("App view switching", () => {
});
it("does not render insights view button when insights experimental feature is disabled", async () => {
// Keep at least one overflow item enabled so the overflow trigger still renders.
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { insights: false },
@@ -2606,14 +2595,13 @@ describe("App view switching", () => {
render(<App />);
// Wait for the header to render
// Wait for the sidebar to render
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
});
// Open the overflow menu - Insights item should not be rendered
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-overflow-insights")).toBeNull();
// Insights is not a sidebar destination when the feature is disabled
expect(screen.queryByTestId("sidebar-nav-insights")).toBeNull();
});
it("keeps experimental views off until settings load and falls back to board when no flag is enabled", async () => {
@@ -2630,7 +2618,7 @@ describe("App view switching", () => {
render(<App />);
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
});
expect(document.querySelector(".insights-view")).toBeNull();
@@ -2650,7 +2638,6 @@ describe("App view switching", () => {
});
it("does not render memory view button when memoryView experimental feature is disabled", async () => {
// Keep another overflow item enabled so the overflow trigger still renders.
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
experimentalFeatures: { memoryView: false, insights: true },
@@ -2658,14 +2645,13 @@ describe("App view switching", () => {
render(<App />);
// Wait for the header to render
// Wait for the sidebar to render
await waitFor(() => {
expect(screen.getByTitle("Board view")).toBeTruthy();
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
});
// Open the overflow menu - Memory item should not be rendered
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
expect(screen.queryByTestId("view-toggle-memory")).toBeNull();
// Memory is not a sidebar destination when the feature is disabled
expect(screen.queryByTestId("sidebar-nav-memory")).toBeNull();
});
it("redirects to board when memoryView experimental feature is disabled and taskView is memory", async () => {
@@ -2730,43 +2716,37 @@ describe("App view switching", () => {
});
describe("App GitHub import", () => {
it("opens GitHub import modal when import button is clicked", async () => {
// FNXC:Navigation 2026-06-22-09:30: GitHub import is now the left-sidebar "Import Tasks"
// destination rendering the GitHubImportModal embedded in main content (presentation="embedded"),
// not a header-button modal overlay. Navigation in/out goes through the sidebar; embedded mode
// has no overlay or Cancel affordance (closing returns to the board view).
it("opens GitHub import as an embedded view from the Import Tasks sidebar destination", async () => {
render(<App />);
// Wait for the header to render
const importNavItem = await screen.findByTestId("sidebar-nav-import-tasks");
fireEvent.click(importNavItem);
await waitFor(() => {
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
expect(screen.getByTestId("github-import-view")).toBeTruthy();
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
// Click the import button
fireEvent.click(screen.getByTitle("Import from GitHub"));
// Modal should be visible
expect(screen.getByText("Import from GitHub")).toBeTruthy();
});
it("closes GitHub import modal on cancel", async () => {
it("closes the embedded GitHub import view back to the board", async () => {
render(<App />);
fireEvent.click(await screen.findByTestId("sidebar-nav-import-tasks"));
await waitFor(() => {
expect(screen.getByTitle("Import from GitHub")).toBeTruthy();
expect(screen.getByTestId("github-import-view")).toBeTruthy();
});
// Open the modal
fireEvent.click(screen.getByTitle("Import from GitHub"));
// Returning to the board is the embedded close path (no overlay/Cancel button).
fireEvent.click(await screen.findByTestId("sidebar-nav-board"));
// Scope interactions to the GitHub import modal to avoid clicking Cancel
// buttons from other overlays (e.g. onboarding wizard).
const modalHeading = await screen.findByRole("heading", { name: "Import from GitHub" });
const modalOverlay = modalHeading.closest(".modal-overlay");
expect(modalOverlay).toBeTruthy();
const cancelButton = within(modalOverlay as HTMLElement).getByRole("button", { name: /^Cancel$/i });
fireEvent.click(cancelButton);
// Modal heading should be gone after cancel closes the overlay.
await waitFor(() => {
expect(screen.queryByRole("heading", { name: "Import from GitHub" })).toBeNull();
expect(screen.queryByTestId("github-import-view")).toBeNull();
expect(document.querySelector(".board")).toBeTruthy();
});
});
});
@@ -3577,7 +3557,7 @@ describe("App onboarding reopen", () => {
fireEvent.click(settingsBtn);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Settings" })).toBeTruthy();
});
// Navigate to Authentication section (it should be default or click to ensure)
@@ -3634,7 +3614,7 @@ describe("App onboarding reopen", () => {
fireEvent.click(settingsBtn);
await waitFor(() => {
expect(screen.getByText("Settings")).toBeTruthy();
expect(screen.getByRole("heading", { name: "Settings" })).toBeTruthy();
});
// Navigate to Authentication section
@@ -4094,7 +4074,7 @@ describe("App board branch filters", () => {
expect(screen.queryByText("Beta Search")).toBeNull();
});
fireEvent.click(screen.getByTitle("List view"));
fireEvent.click(screen.getByTestId("sidebar-nav-list"));
await waitFor(() => {
expect(screen.getByText("Alpha Search")).toBeTruthy();
expect(screen.getByText("Beta Search")).toBeTruthy();

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { DockFilesView } from "../DockFilesView";
import { getScopedItem, scopedKey } from "../../utils/projectStorage";
import type { FileNode } from "../../api";
/*
FNXC:RightDockFiles 2026-06-22-23:30:
Proves the current-file path is shared between the dock instance and the popped-out (expand) instance via scoped storage: selecting a file in the dock persists it, and a freshly mounted expand instance reads it on mount and opens the SAME file in its viewer pane.
*/
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (_key: string, fallback?: string) => fallback ?? _key }),
}));
const entries: FileNode[] = [
{ name: "readme.md", type: "file", size: 10, mtime: "2026-01-15T10:30:00Z" },
];
vi.mock("../../hooks/useWorkspaceFileBrowser", () => ({
useWorkspaceFileBrowser: () => ({
entries,
currentPath: "",
setPath: vi.fn(),
loading: false,
error: null,
refresh: vi.fn(),
}),
}));
const mockFetchContent = vi.fn(() => Promise.resolve({ content: "# hi" }));
vi.mock("../../api", () => ({
fetchWorkspaceFileContent: (...args: unknown[]) => mockFetchContent(...(args as [])),
}));
// Keep the viewer simple: surface the file path it was asked to render.
vi.mock("../FileEditor", () => ({
FileEditor: ({ filePath }: { filePath?: string }) => (
<div data-testid="mock-file-editor" data-file-path={filePath} />
),
}));
// Render the tree's files as buttons so we can click one.
vi.mock("../FileBrowser", () => ({
FileBrowser: ({ entries: e, onSelectFile }: { entries: FileNode[]; onSelectFile: (p: string) => void }) => (
<div data-testid="mock-file-browser">
{e.map((entry) => (
<button key={entry.name} type="button" onClick={() => onSelectFile(entry.name)}>
{entry.name}
</button>
))}
</div>
),
}));
const PROJECT_ID = "proj-1";
const KEY = scopedKey("kb-dashboard-dock-files-current", PROJECT_ID);
describe("DockFilesView shared current-file state", () => {
beforeEach(() => {
window.localStorage.clear();
mockFetchContent.mockClear();
});
afterEach(() => cleanup());
it("persists the selected file to scoped storage and a fresh expand instance reads it on mount", async () => {
// 1. Dock instance: select a file.
const dock = render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("readme.md"));
// The path was persisted to the shared scoped key.
expect(getScopedItem("kb-dashboard-dock-files-current", PROJECT_ID)).toBe("readme.md");
await waitFor(() => {
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
});
// 2. Unmount the dock; mount a SEPARATE expand instance (two-pane pop-out).
dock.unmount();
render(<DockFilesView projectId={PROJECT_ID} layout="two-pane" />);
// The expand instance opened the SAME file from storage on mount.
await waitFor(() => {
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
});
expect(screen.queryByTestId("right-dock-files-empty")).toBeNull();
expect(screen.getByTestId("right-dock-files-view")).toHaveAttribute("data-layout", "two-pane");
});
it("clearing the file (back) clears the shared key", () => {
render(<DockFilesView projectId={PROJECT_ID} layout="auto" />);
fireEvent.click(screen.getByText("readme.md"));
expect(getScopedItem("kb-dashboard-dock-files-current", PROJECT_ID)).toBe("readme.md");
fireEvent.click(screen.getByTestId("right-dock-files-back"));
expect(getScopedItem("kb-dashboard-dock-files-current", PROJECT_ID)).toBeNull();
expect(screen.getByTestId("right-dock-files-empty")).toBeInTheDocument();
});
it("live-syncs from a cross-instance storage event", async () => {
render(<DockFilesView projectId={PROJECT_ID} layout="two-pane" />);
expect(screen.getByTestId("right-dock-files-empty")).toBeInTheDocument();
act(() => {
window.localStorage.setItem(KEY, "readme.md");
window.dispatchEvent(new StorageEvent("storage", { key: KEY, newValue: "readme.md" }));
});
await waitFor(() => {
expect(screen.getByTestId("mock-file-editor")).toHaveAttribute("data-file-path", "readme.md");
});
});
});

View File

@@ -78,66 +78,13 @@ describe("ThemeSelector", () => {
/>
);
expect(screen.getByLabelText("Default theme")).toBeDefined();
expect(screen.getByLabelText("Ocean theme")).toBeDefined();
expect(screen.getByLabelText("Forest theme")).toBeDefined();
expect(screen.getByLabelText("Sunset theme")).toBeDefined();
expect(screen.getByLabelText("Zen theme")).toBeDefined();
expect(screen.getByLabelText("Berry theme")).toBeDefined();
expect(screen.getByLabelText("Mono theme")).toBeDefined();
expect(screen.getByLabelText("High Contrast theme")).toBeDefined();
expect(screen.getByLabelText("Solarized theme")).toBeDefined();
expect(screen.getByLabelText("Factory theme")).toBeDefined();
expect(screen.getByLabelText("Ayu theme")).toBeDefined();
expect(screen.getByLabelText("One Dark theme")).toBeDefined();
expect(screen.getByLabelText("Nord theme")).toBeDefined();
expect(screen.getByLabelText("Dracula theme")).toBeDefined();
expect(screen.getByLabelText("Gruvbox theme")).toBeDefined();
expect(screen.getByLabelText("Tokyo Night theme")).toBeDefined();
expect(screen.getByLabelText("Catppuccin Mocha theme")).toBeDefined();
expect(screen.getByLabelText("GitHub Dark theme")).toBeDefined();
expect(screen.getByLabelText("Everforest theme")).toBeDefined();
expect(screen.getByLabelText("Rosé Pine theme")).toBeDefined();
expect(screen.getByLabelText("Kanagawa theme")).toBeDefined();
expect(screen.getByLabelText("Slate theme")).toBeDefined();
expect(screen.getByLabelText("Ash theme")).toBeDefined();
expect(screen.getByLabelText("Graphite theme")).toBeDefined();
expect(screen.getByLabelText("Silver theme")).toBeDefined();
expect(screen.getByLabelText("Brutalist theme")).toBeDefined();
expect(screen.getByLabelText("Neon City theme")).toBeDefined();
expect(screen.getByLabelText("Parchment theme")).toBeDefined();
expect(screen.getByLabelText("Terminal theme")).toBeDefined();
expect(screen.getByLabelText("Glass theme")).toBeDefined();
expect(screen.getByLabelText("Horizon theme")).toBeDefined();
expect(screen.getByLabelText("Vitesse theme")).toBeDefined();
expect(screen.getByLabelText("Outrun theme")).toBeDefined();
expect(screen.getByLabelText("Snazzy theme")).toBeDefined();
expect(screen.getByLabelText("Porple theme")).toBeDefined();
expect(screen.getByLabelText("Espresso theme")).toBeDefined();
expect(screen.getByLabelText("Mars theme")).toBeDefined();
expect(screen.getByLabelText("Poimandres theme")).toBeDefined();
expect(screen.getByLabelText("Ember theme")).toBeDefined();
expect(screen.getByLabelText("Rust theme")).toBeDefined();
expect(screen.getByLabelText("Copper theme")).toBeDefined();
expect(screen.getByLabelText("Foundry theme")).toBeDefined();
expect(screen.getByLabelText("Carbon theme")).toBeDefined();
expect(screen.getByLabelText("Sandstone theme")).toBeDefined();
expect(screen.getByLabelText("Lagoon theme")).toBeDefined();
expect(screen.getByLabelText("Frost theme")).toBeDefined();
expect(screen.getByLabelText("Lavender theme")).toBeDefined();
expect(screen.getByLabelText("Neon Bloom theme")).toBeDefined();
expect(screen.getByLabelText("Sepia theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Custom theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Blue theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Green theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Red theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Purple theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Pink theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Orange theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Yellow theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Mono theme")).toBeDefined();
expect(screen.getByLabelText("Shadcn Black theme")).toBeDefined();
// FNXC:Theme 2026-06-22-09:30: Assert the accessibility invariant — every theme in the
// shared COLOR_THEMES list renders an accessibly-labeled option — instead of a frozen
// hardcoded label list that drifts whenever themes are renamed/added (e.g. FN-6813 mono variants).
for (const theme of THEME_OPTIONS) {
expect(screen.getByLabelText(`${theme.label} theme`)).toBeDefined();
}
expect(THEME_OPTIONS.map((theme) => theme.value)).toEqual([...COLOR_THEMES]);
});
it("renders every shared swatch class from themeOptions", () => {

View File

@@ -79,10 +79,15 @@ describe("TodoView", () => {
mockUseTodoLists.mockReturnValue(createMockTodoLists());
});
it("renders the docked view header", () => {
// FNXC:Todos 2026-06-22-09:30: FN-6781 removed the redundant in-view "Todos" title +
// subtitle — the right dock / left-sidebar nav already labels the view, so the list/detail
// layout owns the full height with no header above it. Assert the view mounts and the
// redundant header is gone.
it("renders the docked view without a redundant header", () => {
render(<TodoView addToast={addToast} />);
expect(screen.getByRole("heading", { level: 2, name: "Todos" })).toBeInTheDocument();
expect(screen.getByText("Manage reusable todo lists for your project.")).toBeInTheDocument();
expect(screen.getByTestId("todo-view-root")).toBeInTheDocument();
expect(screen.queryByRole("heading", { level: 2, name: "Todos" })).not.toBeInTheDocument();
expect(screen.queryByText("Manage reusable todo lists for your project.")).not.toBeInTheDocument();
});
it("renders sidebar with list names", () => {

View File

@@ -279,9 +279,12 @@ describe("core modals mobile css coverage", () => {
const mobileBlock = getMainMobileBlock(css);
expect(mobileBlock).toContain(".modal-overlay.git-manager-modal-overlay,");
expect(mobileBlock).toContain(".modal.gm-modal[style*=\"--keyboard-overlap\"]");
// FNXC:GitManager 2026-06-22-09:30: The mobile viewport-takeover (and its keyboard rule)
// is now scoped to the NON-embedded dialog via :not(.gm-modal--embedded) so the right-dock
// embedded Git Manager keeps its 100%-of-pane sizing instead of hiding the Header/MobileNavBar.
expect(mobileBlock).toContain(".modal.gm-modal:not(.gm-modal--embedded)[style*=\"--keyboard-overlap\"]");
const keyboardRule = mobileBlock.match(/\.modal\.gm-modal\[style\*=\"--keyboard-overlap\"\]\s*\{[^}]+\}/s);
const keyboardRule = mobileBlock.match(/\.modal\.gm-modal:not\(\.gm-modal--embedded\)\[style\*=\"--keyboard-overlap\"\]\s*\{[^}]+\}/s);
expect(keyboardRule).not.toBeNull();
expect(keyboardRule![0]).toContain("height: var(--vv-height, 100dvh)");
expect(keyboardRule![0]).toContain("min-height: var(--vv-height, 100dvh)");
@@ -311,11 +314,16 @@ describe("core modals mobile css coverage", () => {
it("GitManagerModal: file sections and file lists keep independent scrolling constraints", () => {
const css = loadAllAppCss();
const fileSectionRule = css.match(/\.gm-file-section\s*\{[^}]+\}/s);
expect(fileSectionRule).not.toBeNull();
expect(fileSectionRule![0]).toContain("display: flex");
expect(fileSectionRule![0]).toContain("flex-direction: column");
expect(fileSectionRule![0]).toContain("min-height: 0");
// FNXC:GitManager 2026-06-22-09:30: Multiple .gm-file-section rules exist (base + mobile
// overrides), and concatenation order is not guaranteed, so select the BASE rule by its
// defining flex-column property instead of relying on first-match.
const fileSectionRule = [...css.matchAll(/\.gm-file-section\s*\{[^}]+\}/gs)]
.map((m) => m[0])
.find((rule) => rule.includes("display: flex"));
expect(fileSectionRule).toBeTruthy();
expect(fileSectionRule!).toContain("display: flex");
expect(fileSectionRule!).toContain("flex-direction: column");
expect(fileSectionRule!).toContain("min-height: 0");
const fileListRule = css.match(/\.gm-file-list\s*\{[^}]+\}/s);
expect(fileListRule).not.toBeNull();

View File

@@ -53,15 +53,17 @@ describe("skills-view mobile css", () => {
const cssContent = loadAllAppCss();
const mobileMediaBlock = extractMobileMediaBlocks(cssContent);
it("defines .skills-view-header in mobile block with reduced padding", () => {
expect(mobileMediaBlock).toContain(".skills-view-header");
const block = extractRuleBlock(mobileMediaBlock, ".skills-view-header");
// Base has padding: var(--space-lg) 20px; mobile should override
expect(block).toMatch(/padding:\s*var\(--space-sm\)\s+var\(--space-md\)/);
// FNXC:Skills 2026-06-22-09:30: SkillsView adopted the shared ViewHeader (.view-header /
// .view-header__title) in the redesign, replacing the bespoke .skills-view-header /
// .skills-view-title. Assert the shared header is defined and carries its standard padding/title.
it("uses the shared .view-header for the skills title row", () => {
expect(cssContent).toContain(".view-header {");
const block = extractRuleBlock(cssContent, ".view-header");
expect(block).toMatch(/padding:\s*var\(--space-lg\)/);
});
it("defines .skills-view-title h2 with smaller font on mobile", () => {
expect(cssContent).toContain(".skills-view-title h2");
it("defines the shared .view-header__title", () => {
expect(cssContent).toContain(".view-header__title {");
});
it("defines .skills-view-content with reduced padding on mobile", () => {
@@ -169,8 +171,9 @@ describe("skills-view mobile css", () => {
it("skills-view base styles are defined in styles.css", () => {
expect(cssContent).toContain(".skills-view {");
expect(cssContent).toContain(".skills-view-header {");
expect(cssContent).toContain(".skills-view-title {");
// Header/title row is now the shared .view-header (not bespoke .skills-view-header/-title).
expect(cssContent).toContain(".view-header {");
expect(cssContent).toContain(".view-header__title {");
expect(cssContent).toContain(".skills-view-content {");
expect(cssContent).toContain(".skills-view-section {");
expect(cssContent).toContain(".skills-view-list {");
@@ -259,9 +262,10 @@ describe("SkillsView component structure", () => {
const sections = contentWrapper!.querySelectorAll(".skills-view-section");
expect(sections.length).toBe(2);
// Header should be outside the wrapper (directly on skills-view)
// Header (now the shared ViewHeader: .view-header) should be outside the content
// wrapper, directly on skills-view.
const skillsView = screen.getByTestId("skills-view");
const header = skillsView.querySelector(".skills-view-header");
const header = skillsView.querySelector(".view-header");
expect(header).not.toBeNull();
expect(header!.parentElement).toBe(skillsView);
});

View File

@@ -593,6 +593,8 @@ The "AI Engine" panel is a bordered card that hosts the "View Board"/"View Agent
gap: var(--space-sm);
}
/* FNXC:CommandCenter 2026-06-22-23:30: View Board / View Agents match the Stop AI Engine button (btn btn-secondary, full-row, centered) — taller than the old btn-sm and visually consistent in the AI engine card. */
.cc-overview-engine-nav-btn {
flex: 1 1 auto;
justify-content: center;
}

View File

@@ -186,14 +186,14 @@ export function CommandCenterControls({ projectId, colorTheme, themeMode, shadcn
<div className="cc-overview-engine-nav" data-testid="command-center-engine-panel">
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
className="btn btn-secondary cc-overview-engine-nav-btn"
onClick={() => onChangeView("board")}
>
{t("commandCenter.controls.engine.viewBoard", "View Board")}
</button>
<button
type="button"
className="btn btn-sm cc-overview-engine-nav-btn"
className="btn btn-secondary cc-overview-engine-nav-btn"
onClick={() => onChangeView("agents")}
>
{t("commandCenter.controls.engine.viewAgents", "View Agents")}

View File

@@ -34,6 +34,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
"kb-dashboard-base-branch-filter",
"kb-capacity-risk-banner-dismissed",
"kb-files-line-numbers",
"kb-dashboard-dock-files-current",
"fusion-plugin-dependency-graph:positions",
];