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:
Fusion
2026-04-24 21:08:35 -07:00
committed by gsxdsm
parent 70d77f8ba6
commit 20366ee250
4 changed files with 83 additions and 17 deletions

View File

@@ -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

View File

@@ -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);");
});
});

View File

@@ -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);

View File

@@ -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 }) => {