Phase 1: Dashboard Polish + Theming (#457) (#458)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
ci/woodpecker/push/web Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #458.
This commit is contained in:
2026-02-23 00:16:45 +00:00
committed by jason.woltje
parent 7c55464d54
commit 07f5225a76
17 changed files with 159 additions and 506 deletions

View File

@@ -1,85 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import DashboardPage from "./page";
// Mock dashboard widgets
vi.mock("@/components/dashboard/RecentTasksWidget", () => ({
RecentTasksWidget: ({
tasks,
isLoading,
}: {
tasks: unknown[];
isLoading: boolean;
}): React.JSX.Element => (
<div data-testid="recent-tasks">
{isLoading ? "Loading tasks" : `${String(tasks.length)} tasks`}
</div>
// Mock Phase 3 dashboard widgets
vi.mock("@/components/dashboard/DashboardMetrics", () => ({
DashboardMetrics: (): React.JSX.Element => (
<div data-testid="dashboard-metrics">Dashboard Metrics</div>
),
}));
vi.mock("@/components/dashboard/UpcomingEventsWidget", () => ({
UpcomingEventsWidget: ({
events,
isLoading,
}: {
events: unknown[];
isLoading: boolean;
}): React.JSX.Element => (
<div data-testid="upcoming-events">
{isLoading ? "Loading events" : `${String(events.length)} events`}
</div>
vi.mock("@/components/dashboard/OrchestratorSessions", () => ({
OrchestratorSessions: (): React.JSX.Element => (
<div data-testid="orchestrator-sessions">Orchestrator Sessions</div>
),
}));
vi.mock("@/components/dashboard/QuickCaptureWidget", () => ({
QuickCaptureWidget: (): React.JSX.Element => <div data-testid="quick-capture">Quick Capture</div>,
vi.mock("@/components/dashboard/QuickActions", () => ({
QuickActions: (): React.JSX.Element => <div data-testid="quick-actions">Quick Actions</div>,
}));
vi.mock("@/components/dashboard/DomainOverviewWidget", () => ({
DomainOverviewWidget: ({
tasks,
isLoading,
}: {
tasks: unknown[];
isLoading: boolean;
}): React.JSX.Element => (
<div data-testid="domain-overview">
{isLoading ? "Loading overview" : `${String(tasks.length)} tasks overview`}
</div>
),
vi.mock("@/components/dashboard/ActivityFeed", () => ({
ActivityFeed: (): React.JSX.Element => <div data-testid="activity-feed">Activity Feed</div>,
}));
vi.mock("@/components/dashboard/TokenBudget", () => ({
TokenBudget: (): React.JSX.Element => <div data-testid="token-budget">Token Budget</div>,
}));
describe("DashboardPage", (): void => {
it("should render the page title", (): void => {
it("should render the DashboardMetrics widget", (): void => {
render(<DashboardPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard");
expect(screen.getByTestId("dashboard-metrics")).toBeInTheDocument();
});
it("should show loading state initially", (): void => {
it("should render the OrchestratorSessions widget", (): void => {
render(<DashboardPage />);
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("Loading tasks");
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("Loading events");
expect(screen.getByTestId("domain-overview")).toHaveTextContent("Loading overview");
expect(screen.getByTestId("orchestrator-sessions")).toBeInTheDocument();
});
it("should render all widgets with data after loading", async (): Promise<void> => {
it("should render the QuickActions widget", (): void => {
render(<DashboardPage />);
await waitFor((): void => {
expect(screen.getByTestId("recent-tasks")).toHaveTextContent("4 tasks");
expect(screen.getByTestId("upcoming-events")).toHaveTextContent("3 events");
expect(screen.getByTestId("domain-overview")).toHaveTextContent("4 tasks overview");
expect(screen.getByTestId("quick-capture")).toBeInTheDocument();
});
expect(screen.getByTestId("quick-actions")).toBeInTheDocument();
});
it("should have proper layout structure", (): void => {
const { container } = render(<DashboardPage />);
const main = container.querySelector("main");
expect(main).toBeInTheDocument();
it("should render the ActivityFeed widget", (): void => {
render(<DashboardPage />);
expect(screen.getByTestId("activity-feed")).toBeInTheDocument();
});
it("should render the welcome subtitle", (): void => {
it("should render the TokenBudget widget", (): void => {
render(<DashboardPage />);
expect(screen.getByText(/Welcome back/)).toBeInTheDocument();
expect(screen.getByTestId("token-budget")).toBeInTheDocument();
});
});

View File

@@ -11,13 +11,7 @@ export default function DashboardPage(): ReactElement {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<DashboardMetrics />
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 320px",
gap: 16,
}}
>
<div className="dash-grid">
<div style={{ display: "flex", flexDirection: "column", gap: 16, minWidth: 0 }}>
<OrchestratorSessions />
<QuickActions />

View File

@@ -765,6 +765,62 @@ body::before {
animation: scaleIn 0.1s ease-out;
}
/* -----------------------------------------------------------------------------
Dashboard Layout — Responsive Grids
----------------------------------------------------------------------------- */
.metrics-strip {
display: grid;
grid-template-columns: repeat(var(--ms-cols, 6), 1fr);
gap: 0;
border-radius: var(--r-lg);
overflow: hidden;
border: 1px solid var(--border);
}
.metric-cell {
border-left: 1px solid var(--border);
}
.metric-cell:first-child {
border-left: none;
}
@media (max-width: 900px) {
.metrics-strip {
grid-template-columns: repeat(3, 1fr);
}
.metric-cell:nth-child(3n + 1) {
border-left: none;
}
}
@media (max-width: 640px) {
.metrics-strip {
grid-template-columns: repeat(2, 1fr);
}
.metric-cell:nth-child(3n + 1) {
border-left: 1px solid var(--border);
}
.metric-cell:nth-child(2n + 1) {
border-left: none;
}
}
.dash-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
}
@media (max-width: 900px) {
.dash-grid {
grid-template-columns: 1fr;
}
}
/* -----------------------------------------------------------------------------
Responsive Typography Adjustments
----------------------------------------------------------------------------- */

View File

@@ -102,8 +102,8 @@ function ActivityItemRow({ item }: ActivityItemRowProps): ReactElement {
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
padding: "8px 0",
gap: 12,
padding: "10px 0",
borderBottom: "1px solid var(--border)",
}}
>

View File

@@ -1,65 +0,0 @@
import type { Task } from "@mosaic/shared";
import { TaskStatus, TaskPriority } from "@mosaic/shared";
interface DomainOverviewWidgetProps {
tasks: Task[];
isLoading: boolean;
}
export function DomainOverviewWidget({
tasks,
isLoading,
}: DomainOverviewWidgetProps): React.JSX.Element {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading overview...</span>
</div>
</div>
);
}
const stats = {
total: tasks.length,
inProgress: tasks.filter((t) => t.status === TaskStatus.IN_PROGRESS).length,
completed: tasks.filter((t) => t.status === TaskStatus.COMPLETED).length,
highPriority: tasks.filter((t) => t.priority === TaskPriority.HIGH).length,
};
const StatCard = ({
label,
value,
color,
}: {
label: string;
value: number;
color: string;
}): React.JSX.Element => (
<div className={`p-4 rounded-lg bg-gradient-to-br ${color}`}>
<div className="text-3xl font-bold text-white mb-1">{value}</div>
<div className="text-sm text-white/90">{label}</div>
</div>
);
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Domain Overview</h2>
<div className="grid grid-cols-2 gap-4">
<StatCard label="Total Tasks" value={stats.total} color="from-blue-500 to-blue-600" />
<StatCard
label="In Progress"
value={stats.inProgress}
color="from-green-500 to-green-600"
/>
<StatCard label="Completed" value={stats.completed} color="from-purple-500 to-purple-600" />
<StatCard
label="High Priority"
value={stats.highPriority}
color="from-red-500 to-red-600"
/>
</div>
</div>
);
}

View File

@@ -169,7 +169,7 @@ function OrchCard({ session }: OrchCardProps): ReactElement {
style={{
background: "var(--bg-mid)",
border: "1px solid var(--border)",
borderRadius: "var(--r-md)",
borderRadius: "var(--r)",
padding: "12px 14px",
marginBottom: 10,
}}

View File

@@ -12,10 +12,10 @@ interface QuickAction {
}
const actions: QuickAction[] = [
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.15)" },
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.15)" },
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.15)" },
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.15)" },
{ id: "new-project", label: "New Project", icon: "🚀", iconBg: "rgba(47,128,255,0.12)" },
{ id: "spawn-agent", label: "Spawn Agent", icon: "🤖", iconBg: "rgba(139,92,246,0.12)" },
{ id: "view-telemetry", label: "View Telemetry", icon: "📊", iconBg: "rgba(20,184,166,0.12)" },
{ id: "review-tasks", label: "Review Tasks", icon: "📋", iconBg: "rgba(245,158,11,0.12)" },
];
interface ActionButtonProps {
@@ -36,24 +36,25 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
}}
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 8,
padding: "16px 12px",
borderRadius: "var(--r-md)",
padding: "10px 12px",
borderRadius: "var(--r)",
border: `1px solid ${hovered ? "var(--ms-border-700)" : "var(--border)"}`,
background: hovered ? "var(--surface)" : "var(--bg-mid)",
cursor: "pointer",
transition: "border-color 0.15s, background 0.15s",
transition: "border-color 0.15s, background 0.15s, color 0.15s",
width: "100%",
fontSize: "0.8rem",
fontWeight: 600,
color: hovered ? "var(--text)" : "var(--text-2)",
}}
>
<div
style={{
width: 24,
height: 24,
borderRadius: 6,
borderRadius: 5,
background: action.iconBg,
display: "flex",
alignItems: "center",
@@ -63,15 +64,7 @@ function ActionButton({ action }: ActionButtonProps): ReactElement {
>
{action.icon}
</div>
<span
style={{
fontSize: "0.8rem",
fontWeight: 600,
color: "var(--text)",
}}
>
{action.label}
</span>
<span>{action.label}</span>
</button>
);
}
@@ -84,7 +77,7 @@ export function QuickActions(): ReactElement {
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
gap: 8,
}}
>
{actions.map((action) => (

View File

@@ -1,85 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@mosaic/ui";
import { useRouter } from "next/navigation";
import { ComingSoon } from "@/components/ui/ComingSoon";
/**
* Check if we're in development mode (runtime check for testability)
*/
function isDevelopment(): boolean {
return process.env.NODE_ENV === "development";
}
/**
* Internal Quick Capture Widget implementation
*/
function QuickCaptureWidgetInternal(): React.JSX.Element {
const [idea, setIdea] = useState("");
const router = useRouter();
const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>): void => {
e.preventDefault();
if (!idea.trim()) return;
// TODO: Implement quick capture API call
// For now, just show a success indicator
console.log("Quick capture:", idea);
setIdea("");
};
const goToTasks = (): void => {
router.push("/tasks");
};
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Capture</h2>
<p className="text-sm text-gray-600 mb-4">Quickly jot down ideas or brain dumps</p>
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
value={idea}
onChange={(e) => {
setIdea(e.target.value);
}}
placeholder="What's on your mind?"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
rows={3}
/>
<div className="flex gap-2">
<Button type="submit" variant="primary" size="sm">
Save Note
</Button>
<Button type="button" variant="secondary" size="sm" onClick={goToTasks}>
Create Task
</Button>
</div>
</form>
</div>
);
}
/**
* Quick Capture Widget (Dashboard version)
*
* In production: Shows Coming Soon placeholder
* In development: Full widget functionality
*/
export function QuickCaptureWidget(): React.JSX.Element {
// In production, show Coming Soon placeholder
if (!isDevelopment()) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<ComingSoon
feature="Quick Capture"
description="Quickly jot down ideas for later organization. This feature is currently under development."
className="!p-0 !min-h-0"
/>
</div>
);
}
// In development, show full widget functionality
return <QuickCaptureWidgetInternal />;
}

View File

@@ -1,79 +0,0 @@
import type { Task } from "@mosaic/shared";
import { TaskPriority } from "@mosaic/shared";
import { formatDate } from "@/lib/utils/date-format";
import { TaskStatus } from "@mosaic/shared";
import Link from "next/link";
interface RecentTasksWidgetProps {
tasks: Task[];
isLoading: boolean;
}
const statusIcons: Record<TaskStatus, string> = {
[TaskStatus.NOT_STARTED]: "⚪",
[TaskStatus.IN_PROGRESS]: "🟢",
[TaskStatus.PAUSED]: "⏸️",
[TaskStatus.COMPLETED]: "✅",
[TaskStatus.ARCHIVED]: "💤",
};
export function RecentTasksWidget({ tasks, isLoading }: RecentTasksWidgetProps): React.JSX.Element {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading tasks...</span>
</div>
</div>
);
}
const recentTasks = tasks.slice(0, 5);
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Recent Tasks</h2>
<Link href="/tasks" className="text-sm text-blue-600 hover:text-blue-700">
View all
</Link>
</div>
{recentTasks.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No tasks yet</p>
) : (
<ul className="space-y-3">
{recentTasks.map((task) => (
<li
key={task.id}
className="flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-colors"
>
<span className="text-lg flex-shrink-0" aria-label={`Status: ${task.status}`}>
{statusIcons[task.status]}
</span>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 text-sm truncate">{task.title}</h3>
<div className="flex items-center gap-2 mt-1">
{task.priority !== TaskPriority.LOW && (
<span
className={`text-xs px-2 py-0.5 rounded-full ${
task.priority === TaskPriority.HIGH
? "bg-red-100 text-red-700"
: "bg-blue-100 text-blue-700"
}`}
>
{task.priority}
</span>
)}
{task.dueDate && (
<span className="text-xs text-gray-500">{formatDate(task.dueDate)}</span>
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -1,64 +0,0 @@
import type { Event } from "@mosaic/shared";
import { formatTime, formatDate } from "@/lib/utils/date-format";
import Link from "next/link";
interface UpcomingEventsWidgetProps {
events: Event[];
isLoading: boolean;
}
export function UpcomingEventsWidget({
events,
isLoading,
}: UpcomingEventsWidgetProps): React.JSX.Element {
if (isLoading) {
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex justify-center items-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading events...</span>
</div>
</div>
);
}
const upcomingEvents = events.slice(0, 4);
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
<Link href="/calendar" className="text-sm text-blue-600 hover:text-blue-700">
View calendar
</Link>
</div>
{upcomingEvents.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4">No upcoming events</p>
) : (
<div className="space-y-3">
{upcomingEvents.map((event) => (
<div
key={event.id}
className="flex items-start gap-3 p-3 rounded-lg border-l-4 border-blue-500 bg-gray-50"
>
<div className="flex-shrink-0 text-center min-w-[3.5rem]">
<div className="text-xs text-gray-500 uppercase font-semibold">
{formatDate(event.startTime).split(",")[0]}
</div>
<div className="text-sm font-medium text-gray-900">
{formatTime(event.startTime)}
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 text-sm truncate">{event.title}</h3>
{event.location && (
<p className="text-xs text-gray-500 mt-0.5">📍 {event.location}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -1,93 +0,0 @@
/**
* QuickCaptureWidget (Dashboard) Component Tests
* Tests environment-based behavior
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { QuickCaptureWidget } from "../QuickCaptureWidget";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: (): { push: () => void } => ({
push: vi.fn(),
}),
}));
describe("QuickCaptureWidget (Dashboard)", (): void => {
beforeEach((): void => {
vi.clearAllMocks();
});
afterEach((): void => {
vi.unstubAllEnvs();
});
describe("Development mode", (): void => {
beforeEach((): void => {
vi.stubEnv("NODE_ENV", "development");
});
it("should render the widget form in development", (): void => {
render(<QuickCaptureWidget />);
// Should show the header
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
// Should show the textarea
expect(screen.getByRole("textbox")).toBeInTheDocument();
// Should show the Save Note button
expect(screen.getByRole("button", { name: /save note/i })).toBeInTheDocument();
// Should show the Create Task button
expect(screen.getByRole("button", { name: /create task/i })).toBeInTheDocument();
// Should NOT show Coming Soon badge
expect(screen.queryByText("Coming Soon")).not.toBeInTheDocument();
});
it("should have a placeholder for the textarea", (): void => {
render(<QuickCaptureWidget />);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveAttribute("placeholder", "What's on your mind?");
});
});
describe("Production mode", (): void => {
beforeEach((): void => {
vi.stubEnv("NODE_ENV", "production");
});
it("should show Coming Soon placeholder in production", (): void => {
render(<QuickCaptureWidget />);
// Should show Coming Soon badge
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
// Should show feature name
expect(screen.getByText("Quick Capture")).toBeInTheDocument();
// Should NOT show the textarea
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
// Should NOT show the buttons
expect(screen.queryByRole("button", { name: /save note/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /create task/i })).not.toBeInTheDocument();
});
it("should show description in Coming Soon placeholder", (): void => {
render(<QuickCaptureWidget />);
expect(screen.getByText(/jot down ideas for later organization/i)).toBeInTheDocument();
});
});
describe("Test mode (non-development)", (): void => {
beforeEach((): void => {
vi.stubEnv("NODE_ENV", "test");
});
it("should show Coming Soon placeholder in test mode", (): void => {
render(<QuickCaptureWidget />);
// Test mode is not development, so should show Coming Soon
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
});
});
});

View File

@@ -356,7 +356,7 @@ function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element
alignItems: "center",
gap: "11px",
padding: "9px 10px",
borderRadius: "6px",
borderRadius: "var(--r-sm)",
fontSize: "0.875rem",
fontWeight: 500,
color: isActive ? "var(--text)" : "var(--muted)",