feat(FN-1631): merge fusion/fn-1631
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user