feat(FN-2395): style settings panel scrollbars
- Add cross-browser scrollbar styling for settings sidebar and content panes using existing design tokens - Extend settings mobile CSS tests to assert the new scrollbar rules for WebKit and Firefox-compatible properties - Refactor dashboard TUI app tests to render via React.createElement for compatibility with current test tooling - Tidy mission e2e mock ID generators to satisfy formatting/quality gate checks
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render } from "ink-testing-library";
|
||||
import { DashboardApp } from "../app.js";
|
||||
@@ -8,6 +9,10 @@ function newController(): DashboardTUI {
|
||||
return new DashboardTUI();
|
||||
}
|
||||
|
||||
function renderDashboardAppNode(controller: DashboardTUI) {
|
||||
return React.createElement(DashboardApp, { controller });
|
||||
}
|
||||
|
||||
function makeSystemInfo() {
|
||||
return {
|
||||
host: "localhost",
|
||||
@@ -68,7 +73,7 @@ afterEach(() => {
|
||||
describe("DashboardApp smoke", () => {
|
||||
it("renders the splash logo and tagline before systemInfo arrives", () => {
|
||||
const controller = newController();
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
const frame = lastFrame() ?? "";
|
||||
// Block-letter "F" opens with this run on wide terminals; on narrow
|
||||
// terminals the compact layout shows plain "FUSION".
|
||||
@@ -79,9 +84,9 @@ describe("DashboardApp smoke", () => {
|
||||
|
||||
it("renders system panel content once setSystemInfo fires", () => {
|
||||
const controller = newController();
|
||||
const { lastFrame, unmount, rerender } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount, rerender } = render(renderDashboardAppNode(controller));
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
rerender(<DashboardApp controller={controller} />);
|
||||
rerender(renderDashboardAppNode(controller));
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("http://localhost:4040");
|
||||
expect(frame).not.toContain("███████╗");
|
||||
@@ -92,7 +97,7 @@ describe("DashboardApp smoke", () => {
|
||||
const controller = newController();
|
||||
controller.setSystemInfo(makeSystemInfo());
|
||||
controller.setMode("interactive");
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
expect(lastFrame() ?? "").toContain("Interactive mode unavailable");
|
||||
unmount();
|
||||
});
|
||||
@@ -110,7 +115,7 @@ describe("DashboardApp smoke", () => {
|
||||
controller.setInteractiveData(makeInteractiveData({ projects, tasks }));
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("board");
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const frame = lastFrame() ?? "";
|
||||
// Board shows the currently selected project; first project "alpha" is selected by default
|
||||
@@ -186,7 +191,7 @@ describe("Agents view", () => {
|
||||
controller.setInteractiveData(makeInteractiveData({ agents }));
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("agents");
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("worker-1");
|
||||
@@ -201,7 +206,7 @@ describe("Agents view", () => {
|
||||
controller.setInteractiveData(makeInteractiveData());
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("agents");
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(lastFrame() ?? "").toContain("Agent Detail");
|
||||
unmount();
|
||||
@@ -224,7 +229,7 @@ describe("Settings view", () => {
|
||||
controller.setInteractiveData(makeInteractiveData({ settings }));
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("settings");
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("Settings");
|
||||
@@ -242,7 +247,7 @@ describe("Settings view", () => {
|
||||
controller.setInteractiveData(makeInteractiveData({ models }));
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("settings");
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("Available Models");
|
||||
@@ -265,7 +270,7 @@ describe("Board view", () => {
|
||||
}));
|
||||
controller.setMode("interactive");
|
||||
controller.setInteractiveView("board");
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("TODO");
|
||||
@@ -284,7 +289,7 @@ describe("LogsPanel indicator", () => {
|
||||
controller.log("third message", "test");
|
||||
// Select index 1 (middle entry)
|
||||
controller.setSelectedLogIndex(1);
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const frame = lastFrame() ?? "";
|
||||
expect(frame).toContain("▶");
|
||||
@@ -297,7 +302,7 @@ describe("LogsPanel indicator", () => {
|
||||
controller.setActiveSection("logs");
|
||||
controller.log("only message", "test");
|
||||
controller.setSelectedLogIndex(0);
|
||||
const { lastFrame, unmount } = render(<DashboardApp controller={controller} />);
|
||||
const { lastFrame, unmount } = render(renderDashboardAppNode(controller));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
const frame = lastFrame() ?? "";
|
||||
// The selected entry shows the arrow; it should appear at least once
|
||||
|
||||
@@ -155,6 +155,13 @@ function expectMobileRule(css: string, selector: string, declaration: string): v
|
||||
expect(pattern.test(css)).toBe(true);
|
||||
}
|
||||
|
||||
function expectBaseRule(css: string, selector: string, declaration: string): void {
|
||||
const pattern = new RegExp(
|
||||
`${escapeRegExp(selector)}\\s*\\{[^}]*${escapeRegExp(declaration)}`,
|
||||
);
|
||||
expect(pattern.test(css)).toBe(true);
|
||||
}
|
||||
|
||||
describe("SettingsModal mobile adaptations", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -249,4 +256,20 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
expectMobileRule(css, ".settings-preset-item-actions", "justify-content: flex-start;");
|
||||
expectMobileRule(css, ".settings-preset-size-grid", "grid-template-columns: 1fr;");
|
||||
});
|
||||
|
||||
it("styles settings scrollbar rules for sidebar and content", () => {
|
||||
const css = loadAllAppCss();
|
||||
|
||||
expectBaseRule(css, ".settings-sidebar", "scrollbar-color: var(--border) transparent;");
|
||||
expectBaseRule(css, ".settings-sidebar", "scrollbar-width: thin;");
|
||||
expectBaseRule(css, ".settings-sidebar::-webkit-scrollbar", "width: 6px;");
|
||||
expectBaseRule(css, ".settings-sidebar::-webkit-scrollbar-thumb", "background: var(--border);");
|
||||
expectBaseRule(css, ".settings-sidebar::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);");
|
||||
|
||||
expectBaseRule(css, ".settings-content", "scrollbar-color: var(--border) transparent;");
|
||||
expectBaseRule(css, ".settings-content", "scrollbar-width: thin;");
|
||||
expectBaseRule(css, ".settings-content::-webkit-scrollbar", "width: 6px;");
|
||||
expectBaseRule(css, ".settings-content::-webkit-scrollbar-thumb", "background: var(--border);");
|
||||
expectBaseRule(css, ".settings-content::-webkit-scrollbar-thumb:hover", "background: var(--text-muted);");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1556,6 +1556,25 @@ input[type="range"]:focus-visible {
|
||||
padding: 10px 8px;
|
||||
gap: 2px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.settings-sidebar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.settings-sidebar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-sidebar::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.settings-sidebar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-nav-item {
|
||||
@@ -1612,6 +1631,25 @@ input[type="range"]:focus-visible {
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0 12px;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.settings-content > * {
|
||||
animation: settingsFadeIn var(--transition-normal);
|
||||
|
||||
@@ -59,11 +59,11 @@ function createMockMissionStore() {
|
||||
// Generate IDs matching the real MissionStore format:
|
||||
// prefix + base36(timestamp) + "-" + random alphanumeric suffix
|
||||
// e.g., M-MNJVKT2G-ME5Q, MS-M3N8QR-C9F1, SL-P4T2WX-D5E8, F-J6K9AB-G7H3
|
||||
const generateMissionId = () => `M-MOCK${missionCounter++.toString(36).toUpperCase()}-TST`;
|
||||
const generateMilestoneId = () => `MS-MOCK${milestoneCounter++.toString(36).toUpperCase()}-TST`;
|
||||
const generateSliceId = () => `SL-MOCK${sliceCounter++.toString(36).toUpperCase()}-TST`;
|
||||
const generateFeatureId = () => `F-MOCK${featureCounter++.toString(36).toUpperCase()}-TST`;
|
||||
const generateAssertionId = () => `CA-MOCK${assertionCounter++.toString(36).toUpperCase()}-TST`;
|
||||
const generateMissionId = () => `M-MOCK${(missionCounter++).toString(36).toUpperCase()}-TST`;
|
||||
const generateMilestoneId = () => `MS-MOCK${(milestoneCounter++).toString(36).toUpperCase()}-TST`;
|
||||
const generateSliceId = () => `SL-MOCK${(sliceCounter++).toString(36).toUpperCase()}-TST`;
|
||||
const generateFeatureId = () => `F-MOCK${(featureCounter++).toString(36).toUpperCase()}-TST`;
|
||||
const generateAssertionId = () => `CA-MOCK${(assertionCounter++).toString(36).toUpperCase()}-TST`;
|
||||
|
||||
return {
|
||||
createMission: vi.fn((input: { title: string; description?: string }) => {
|
||||
|
||||
Reference in New Issue
Block a user