feat(FN-1631): merge fusion/fn-1631

This commit is contained in:
gsxdsm
2026-04-13 18:58:26 -07:00
parent db43409316
commit 27e7a4389d
3 changed files with 308 additions and 2 deletions

View File

@@ -320,6 +320,7 @@ Tasks can get into contradictory states (e.g., `column: "done"` with `status: "b
- When checking if a CSS value is inside a `@media` block, don't just search backwards for the nearest `@media` — track brace depth to confirm the line is actually between the block's opening `{` and closing `}`. Many component styles are defined globally (not in media queries) even though they visually only appear on mobile.
- Regex tests using `[\s\S]*` (greedy match across lines) to check CSS rules inside `@media` blocks are unreliable — they can match across block boundaries. Use non-greedy `[^}]*` scoped to a single rule block instead.
- Touch target sizing in `styles.css` mobile media queries uses 36px (reduced from the original 44px). The `.touch-target` opt-in utility class remains at 44px. Comments mentioning "44px" in the mobile sections have been updated to reflect the actual values.
- **CSS specificity with BEM modifiers (FN-1631)**: When a component has both container state (`.quick-entry-box--expanded`) and element modifier (`.quick-entry-input--expanded`) classes, the container selector may have higher specificity than the modifier selector. For example, `.quick-entry-box--expanded .quick-entry-input` (0,2,1) beats `.quick-entry-input--expanded` (0,1,0). To fix, use `:not(.quick-entry-input--expanded)` to ensure container selectors only affect non-modified elements: `.quick-entry-box--expanded .quick-entry-input:not(.quick-entry-input--expanded)`. This allows the modifier class's rules to take precedence when the modifier is active.
## FN-1464: Mobile Bottom-Spacing Contract

View File

@@ -0,0 +1,301 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { QuickEntryBox } from "../components/QuickEntryBox";
import type { Task } from "@fusion/core";
import { fetchSettings, fetchAgents } from "../api";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
// Minimal task list for deps
const mockTasks: Task[] = [
{
id: "FN-001",
title: "Test task 1",
description: "First test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
];
// Mock the api module
vi.mock("../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [],
favoriteProviders: [],
favoriteModels: [],
}),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
}),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: unknown) => (err as Error)?.message || "Failed"),
fetchAgents: vi.fn().mockResolvedValue([]),
uploadAttachment: vi.fn().mockResolvedValue({}),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Link: () => null,
Paperclip: () => null,
Brain: () => null,
Lightbulb: () => null,
ListTree: () => null,
Sparkles: () => null,
Save: () => null,
X: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
ChevronRight: () => null,
Bot: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));
vi.mock("../components/ModelSelectionModal", () => ({
ModelSelectionModal: () => null,
}));
vi.mock("../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
onChange,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) => <div data-testid={`mock-dropdown-${label}`}>{value || "none"}</div>,
}));
function renderQuickEntryBox(props = {}) {
const defaultProps = {
onCreate: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
tasks: mockTasks,
projectId: "test-proj",
};
return render(<QuickEntryBox {...defaultProps} {...props} />);
}
function expandQuickEntry() {
const toggleButton = screen.getByTestId("quick-entry-toggle");
fireEvent.click(toggleButton);
}
function mockDesktopViewport() {
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
describe("quick-entry-expanded-height CSS contract (FN-1631)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: vi.fn(() => "blob:mock"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
writable: true,
value: vi.fn(),
});
});
afterEach(async () => {
await act(async () => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();
localStorage.clear();
});
/**
* CSS Contract Test:
* When the quick-entry textarea is expanded (has class `quick-entry-input--expanded`),
* its min-height should be GREATER than when collapsed.
*
* This test guards against CSS specificity regressions where container-state selectors
* (`.quick-entry-box--expanded .quick-entry-input`) inadvertently override the
* expanded-height rules set by `.quick-entry-input--expanded`.
*/
it("expanded class applies when quick-entry is expanded via toggle", () => {
mockDesktopViewport();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
const box = screen.getByTestId("quick-entry-box");
// Initially collapsed
expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true);
expect(box.classList.contains("quick-entry-box--expanded")).toBe(false);
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
// Expand via toggle
expandQuickEntry();
// Verify expanded state
expect(box.classList.contains("quick-entry-box--expanded")).toBe(true);
expect(box.classList.contains("quick-entry-box--collapsed")).toBe(false);
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
});
/**
* Guards against the specific CSS specificity bug where
* `.quick-entry-box--expanded .quick-entry-input` (higher specificity)
* overrides `.quick-entry-input--expanded { min-height: 80px; }`
*
* We verify the CSS selectors have correct specificity by checking the stylesheet.
*/
it("CSS uses :not() to prevent container selectors from overriding expanded min-height", () => {
// Read the CSS file
const cssPath = resolve(__dirname, "../styles.css");
const cssContent = readFileSync(cssPath, "utf-8");
// Find the quick-entry-box--expanded rule
const expandedRuleMatch = cssContent.match(
/\.quick-entry-box--expanded\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)\s*\{[^}]*min-height:\s*(\d+)px/,
);
// Find the quick-entry-input--expanded rule
const inputExpandedRuleMatch = cssContent.match(
/\.quick-entry-input--expanded\s*\{[^}]*min-height:\s*(\d+)px/,
);
// Both selectors should exist
expect(expandedRuleMatch).not.toBeNull();
expect(inputExpandedRuleMatch).not.toBeNull();
if (expandedRuleMatch && inputExpandedRuleMatch) {
const containerMinHeight = parseInt(expandedRuleMatch[1], 10);
const expandedMinHeight = parseInt(inputExpandedRuleMatch[1], 10);
// The expanded input min-height (80px) should be greater than container min-height (36px)
expect(expandedMinHeight).toBeGreaterThan(containerMinHeight);
expect(expandedMinHeight).toBe(80);
expect(containerMinHeight).toBe(36);
}
});
/**
* Verify the CSS uses :not() to prevent container override when input is expanded.
* This ensures the fix is using the correct approach.
*/
it("CSS container selectors use :not(.quick-entry-input--expanded) pattern", () => {
const cssPath = resolve(__dirname, "../styles.css");
const cssContent = readFileSync(cssPath, "utf-8");
// Check that .quick-entry-box--expanded uses :not() to avoid overriding expanded input
expect(cssContent).toMatch(
/\.quick-entry-box--expanded\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)/,
);
// Check that .quick-entry-box--collapsed also uses :not() for consistency
expect(cssContent).toMatch(
/\.quick-entry-box--collapsed\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)/,
);
// Check that .quick-entry-input--expanded still sets 80px min-height
expect(cssContent).toMatch(/\.quick-entry-input--expanded\s*\{[^}]*min-height:\s*80px/);
});
/**
* Regression test: Verify focus-triggered expansion also applies the expanded class
*/
it("focus-triggered expansion applies expanded class", () => {
mockDesktopViewport();
renderQuickEntryBox({ autoExpand: true });
const textarea = screen.getByTestId("quick-entry-input");
// Focus triggers auto-expand
fireEvent.focus(textarea);
// Expanded class should be applied
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(true);
});
/**
* Regression test: Verify collapsed state maintains correct classes
*/
it("collapsed state has correct classes (32px equivalent)", () => {
mockDesktopViewport();
renderQuickEntryBox();
const textarea = screen.getByTestId("quick-entry-input");
const box = screen.getByTestId("quick-entry-box");
// Collapsed state
expect(box.classList.contains("quick-entry-box--collapsed")).toBe(true);
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
// Verify the CSS has collapsed min-height of 32px
const cssPath = resolve(__dirname, "../styles.css");
const cssContent = readFileSync(cssPath, "utf-8");
const collapsedRuleMatch = cssContent.match(
/\.quick-entry-box--collapsed\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)\s*\{[^}]*min-height:\s*(\d+)px/,
);
expect(collapsedRuleMatch).not.toBeNull();
if (collapsedRuleMatch) {
const collapsedMinHeight = parseInt(collapsedRuleMatch[1], 10);
expect(collapsedMinHeight).toBe(32);
}
});
/**
* Integration test: When box is expanded but input is not expanded,
* container-level min-height (36px) should apply.
*
* Note: The toggle button always expands both box and input together.
* The only way to have box expanded but input not expanded is via blur
* when autoCollapse behavior is triggered. Since autoCollapse is not
* implemented, this test verifies the CSS has the correct values for
* this case if it ever becomes possible.
*/
it("CSS has correct 36px container min-height for box expanded but input not expanded", () => {
// This test verifies the CSS file has the correct container min-height (36px)
// for the case where the box is expanded but the input is not.
// In practice, the toggle always expands both, but the CSS still needs
// to handle this case correctly.
const cssPath = resolve(__dirname, "../styles.css");
const cssContent = readFileSync(cssPath, "utf-8");
// Check that the expanded box without expanded input uses 36px
const expandedNonExpandedMatch = cssContent.match(
/\.quick-entry-box--expanded\s+\.quick-entry-input:not\(\.quick-entry-input--expanded\)\s*\{[^}]*min-height:\s*(\d+)px/,
);
expect(expandedNonExpandedMatch).not.toBeNull();
if (expandedNonExpandedMatch) {
const minHeight = parseInt(expandedNonExpandedMatch[1], 10);
expect(minHeight).toBe(36);
}
});
});

View File

@@ -14406,7 +14406,9 @@ html .column.drag-over * {
padding: 8px 10px;
}
.quick-entry-box--expanded .quick-entry-input {
/* Only apply container-level min-height when input is NOT expanded.
This prevents container selectors from overriding the expanded min-height. */
.quick-entry-box--expanded .quick-entry-input:not(.quick-entry-input--expanded) {
min-height: 36px;
}
@@ -14415,7 +14417,9 @@ html .column.drag-over * {
padding: 6px 8px;
}
.quick-entry-box--collapsed .quick-entry-input {
/* Only apply container-level min-height when input is NOT expanded.
When input is expanded (e.g., via focus), use the expanded min-height. */
.quick-entry-box--collapsed .quick-entry-input:not(.quick-entry-input--expanded) {
min-height: 32px;
border-bottom-color: transparent;
}