feat(FN-795): add mobile responsive layout for Activity Log modal

- Add mobile responsive CSS for Activity Log modal with adaptive breakpoints
- Add regression test coverage for mobile activity log layout
- Add tests for ActivityLogModal responsive behavior
- Update README with responsive layout documentation note
This commit is contained in:
gsxdsm
2026-04-03 17:52:36 -07:00
parent fdf6d0b4a1
commit 4b7b65b1a6
4 changed files with 308 additions and 0 deletions

View File

@@ -236,6 +236,7 @@ View a centralized timeline of all task lifecycle events. Click the history icon
- **Auto-refresh**: Log updates automatically every 30 seconds when the modal is open
- **Pagination**: "Load More" button fetches older entries (100 entries per request, max 1000)
- **Clear Log**: Maintenance function to clear all activity history (with confirmation)
- **Responsive Layout**: On narrow screens (≤768px), the modal adapts with a stacked header, full-width filter controls, wrapped active-filters bar, reflowed entry text, and vertically stacked confirmation actions — preserving access to filters, task links, and clear-log on mobile devices
**Event Metadata**:
- Task moves show from/to column transitions

View File

@@ -0,0 +1,99 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
/**
* Stylesheet regression test for Activity Log mobile layout.
*
* Parses `packages/dashboard/app/styles.css` and asserts that an
* `@media (max-width: 768px)` block contains Activity Log mobile rules
* for stacked/wrapped controls and entry layout. These selectors must
* remain inside a mobile media query so the Activity Log renders
* correctly on narrow screens.
*/
describe("activity-log-mobile-layout.css", () => {
const cssPath = resolve(__dirname, "../styles.css");
const cssContent = readFileSync(cssPath, "utf-8");
/** Extract all content inside @media (max-width: 768px) blocks. */
function extractMobileMediaBlocks(content: string): string {
const blocks: string[] = [];
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
let match;
while ((match = regex.exec(content)) !== null) {
const startIdx = match.index + match[0].length;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount++;
if (content[endIdx] === "}") braceCount--;
endIdx++;
}
if (braceCount === 0) {
blocks.push(content.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
const mobileCss = extractMobileMediaBlocks(cssContent);
// ── Modal header / actions ──────────────────────────────────────────
it("has mobile rule for activity-log-header to wrap on narrow screens", () => {
expect(mobileCss).toMatch(/\.activity-log-header\s*\{[^}]*flex-wrap:\s*wrap/);
});
it("has mobile rule for activity-log-actions to wrap and fill full width", () => {
expect(mobileCss).toMatch(/\.activity-log-actions\s*\{[^}]*flex-wrap:\s*wrap/);
expect(mobileCss).toMatch(/\.activity-log-actions\s*\{[^}]*flex:\s*1\s+1\s+100%/);
});
// ── Filter controls ─────────────────────────────────────────────────
it("has mobile rule for filter containers to fill available width", () => {
expect(mobileCss).toMatch(/\.activity-log-filter/);
expect(mobileCss).toMatch(/\.activity-log-filter--project/);
});
it("has mobile rule for filter selects to fill width", () => {
expect(mobileCss).toMatch(/\.activity-log-filter-select\s*\{[^}]*width:\s*100%/);
});
// ── Active filters bar ──────────────────────────────────────────────
it("has mobile rule for active-filters bar to wrap", () => {
expect(mobileCss).toMatch(/\.activity-log-active-filters\s*\{[^}]*flex-wrap:\s*wrap/);
});
it("resets clear-filters margin-left on mobile so it doesn't force right", () => {
expect(mobileCss).toMatch(/\.activity-log-clear-filters\s*\{[^}]*margin-left:\s*0/);
});
// ── Entry layout ────────────────────────────────────────────────────
it("has mobile rule for entry details to wrap with word-break", () => {
expect(mobileCss).toMatch(/\.activity-log-entry-details\s*\{[^}]*word-break:\s*break-word/);
});
it("has mobile rule for entry text to break words", () => {
expect(mobileCss).toMatch(/\.activity-log-entry-text\s*\{[^}]*word-break:\s*break-word/);
});
it("has mobile rule for entry headers to wrap", () => {
expect(mobileCss).toMatch(/\.activity-log-entry-header\s*\{[^}]*flex-wrap:\s*wrap/);
});
// ── Confirmation dialog ─────────────────────────────────────────────
it("has mobile rule for confirm actions to stack vertically", () => {
expect(mobileCss).toMatch(/\.activity-log-confirm-actions\s*\{[^}]*flex-direction:\s*column/);
});
it("has mobile rule for confirm buttons to fill width", () => {
expect(mobileCss).toMatch(/\.activity-log-confirm-cancel[^}]*width:\s*100%/);
expect(mobileCss).toMatch(/\.activity-log-confirm-clear[^}]*width:\s*100%/);
});
});

View File

@@ -381,4 +381,107 @@ describe("ActivityLogModal", () => {
expect(screen.getByText(/No activity matches the current filters/)).toBeTruthy();
});
});
// ── Responsive Layout Regression Tests ───────────────────────────
it("renders all mobile-responsive CSS classes on the modal structure", async () => {
const { container } = render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
await waitFor(() => {
expect(screen.getByTestId("activity-log-modal")).toBeTruthy();
});
// Verify key structural classes that the mobile CSS targets
const modal = container.querySelector(".activity-log-modal");
expect(modal).toBeTruthy();
expect(modal!.querySelector(".activity-log-header")).toBeTruthy();
expect(modal!.querySelector(".activity-log-title")).toBeTruthy();
expect(modal!.querySelector(".activity-log-actions")).toBeTruthy();
expect(modal!.querySelector(".activity-log-content")).toBeTruthy();
expect(modal!.querySelector(".activity-log-list")).toBeTruthy();
expect(modal!.querySelector(".activity-log-entry")).toBeTruthy();
});
it("renders entry header and details within each entry for mobile reflow", async () => {
const { container } = render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
await waitFor(() => {
expect(screen.getAllByTestId("activity-entry")).toHaveLength(3);
});
// Each entry should have the inner structure that mobile CSS reflows
const entries = container.querySelectorAll(".activity-log-entry");
for (const entry of entries) {
expect(entry.querySelector(".activity-log-entry-icon")).toBeTruthy();
expect(entry.querySelector(".activity-log-entry-content")).toBeTruthy();
expect(entry.querySelector(".activity-log-entry-header")).toBeTruthy();
expect(entry.querySelector(".activity-log-entry-type")).toBeTruthy();
expect(entry.querySelector(".activity-log-entry-time")).toBeTruthy();
expect(entry.querySelector(".activity-log-entry-details")).toBeTruthy();
}
});
it("renders active-filters bar with correct classes when filter is active", async () => {
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
// Apply a type filter to show the active-filters bar
const filterSelect = await screen.findByTestId("activity-filter");
fireEvent.change(filterSelect, { target: { value: "task:created" } });
await waitFor(() => {
const activeFilters = document.querySelector(".activity-log-active-filters");
expect(activeFilters).toBeTruthy();
expect(activeFilters!.querySelector(".activity-log-filter-label")).toBeTruthy();
expect(activeFilters!.querySelector(".activity-log-filter-badge")).toBeTruthy();
expect(activeFilters!.querySelector(".activity-log-clear-filters")).toBeTruthy();
});
});
it("renders clear-confirmation dialog with stacked action classes", async () => {
render(
<ActivityLogModal
isOpen={true}
onClose={mockOnClose}
tasks={mockTasks}
onOpenTaskDetail={mockOnOpenTaskDetail}
/>
);
await waitFor(() => {
expect(screen.getByTestId("activity-clear")).toBeTruthy();
});
fireEvent.click(screen.getByTestId("activity-clear"));
// Confirm dialog has the structure that mobile CSS stacks
const overlay = document.querySelector(".activity-log-confirm-overlay");
expect(overlay).toBeTruthy();
const dialog = overlay!.querySelector(".activity-log-confirm-dialog");
expect(dialog).toBeTruthy();
const actions = dialog!.querySelector(".activity-log-confirm-actions");
expect(actions).toBeTruthy();
expect(actions!.querySelector(".activity-log-confirm-cancel")).toBeTruthy();
expect(actions!.querySelector(".activity-log-confirm-clear")).toBeTruthy();
});
});

View File

@@ -12273,6 +12273,111 @@ html .column.drag-over * {
border-color: #dc2626;
}
/* ── Activity Log — Mobile (≤ 768px) ─────────────────────────────── */
@media (max-width: 768px) {
/* Header: stack title above actions */
.activity-log-header {
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
}
.activity-log-title {
flex: 1 1 auto;
}
.activity-log-actions {
flex: 1 1 100%;
flex-wrap: wrap;
gap: var(--space-xs);
}
/* Filter containers fill available width */
.activity-log-filter,
.activity-log-filter--project {
flex: 1 1 0;
min-width: 0;
}
.activity-log-filter-select {
width: 100%;
}
/* Action buttons stay inline but shrink */
.activity-log-refresh,
.activity-log-clear,
.activity-log-close {
width: 28px;
height: 28px;
flex-shrink: 0;
}
/* Active filters bar wraps on small screens */
.activity-log-active-filters {
flex-wrap: wrap;
padding: var(--space-sm) var(--space-lg);
gap: var(--space-xs);
}
.activity-log-clear-filters {
margin-left: 0;
}
/* Content area tighter padding */
.activity-log-content {
padding: var(--space-md) var(--space-lg);
min-height: 200px;
}
/* Entries: allow content to wrap freely */
.activity-log-entry {
gap: var(--space-sm);
padding: var(--space-sm);
}
.activity-log-entry-icon {
width: 24px;
height: 24px;
}
.activity-log-entry-header {
flex-wrap: wrap;
gap: var(--space-xs);
}
.activity-log-entry-details {
flex-wrap: wrap;
word-break: break-word;
}
.activity-log-entry-text {
word-break: break-word;
}
/* Empty state compact */
.activity-log-empty {
padding: var(--space-xl) var(--space-lg);
}
/* Confirmation dialog fills more of the viewport */
.activity-log-confirm-dialog {
max-width: calc(100vw - 32px);
margin: 0 var(--space-lg);
}
.activity-log-confirm-actions {
flex-direction: column;
gap: var(--space-sm);
}
.activity-log-confirm-cancel,
.activity-log-confirm-clear {
width: 100%;
text-align: center;
}
}
/* ══════════════════════════════════════════════════════════════════
GIT MANAGER MODAL
══════════════════════════════════════════════════════════════════ */