feat(web): MS15 Phase 1 — Design System & App Shell (#451)
All checks were 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 #451.
This commit is contained in:
2026-02-22 20:57:06 +00:00
committed by jason.woltje
parent 9b5c15ca56
commit a5ed260fbd
15 changed files with 2451 additions and 313 deletions

View File

@@ -0,0 +1,647 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/lib/auth/auth-context";
import { ThemeToggle } from "./ThemeToggle";
import { useSidebar } from "./SidebarContext";
/**
* Full-width application header (topbar).
* Logo/brand MUST live here — not in the sidebar — per MS15 design spec.
* Spans grid-column 1 / -1 in the app shell grid layout.
*
* Layout (left → right):
* [Logo/Brand] [Breadcrumb] [Search] [spacer]
* [System Status] [Terminal Toggle] [Notifications] [Theme Toggle] [User Avatar+Dropdown]
*/
export function AppHeader(): React.JSX.Element {
const { user, signOut } = useAuth();
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
const pathname = usePathname();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchFocused, setSearchFocused] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
const handleOutsideClick = useCallback((event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setDropdownOpen(false);
}
}, []);
useEffect(() => {
if (dropdownOpen) {
document.addEventListener("mousedown", handleOutsideClick);
} else {
document.removeEventListener("mousedown", handleOutsideClick);
}
return (): void => {
document.removeEventListener("mousedown", handleOutsideClick);
};
}, [dropdownOpen, handleOutsideClick]);
// Derive breadcrumb segments from pathname
const breadcrumbSegments = pathname
.split("/")
.filter(Boolean)
.map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1).replace(/-/g, " "));
// User initials for avatar fallback
const initials = user?.name
? user.name
.split(" ")
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase()
: user?.email
? (user.email[0] ?? "?").toUpperCase()
: "?";
const handleHamburgerClick = useCallback((): void => {
if (isMobile) {
setMobileOpen(!mobileOpen);
} else {
toggleCollapsed();
}
}, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]);
return (
<header className="app-header">
{/* ── Hamburger — visible below lg ── */}
<button
type="button"
className="lg:hidden"
onClick={handleHamburgerClick}
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
aria-expanded={mobileOpen}
aria-controls="app-sidebar"
style={{
width: 34,
height: 34,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
background: "none",
border: "none",
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "none";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M2 4h12M2 8h12M2 12h12" />
</svg>
</button>
{/* ── Brand / Logo ── */}
<Link
href="/"
className="flex items-center gap-2 flex-shrink-0"
aria-label="Mosaic Stack home"
>
{/* Mosaic logo mark: four colored squares + center dot */}
<div
className="relative flex-shrink-0"
style={{ width: 28, height: 28 }}
aria-hidden="true"
>
<span
className="absolute rounded-sm"
style={{ top: 0, left: 0, width: 11, height: 11, background: "var(--ms-blue-500)" }}
/>
<span
className="absolute rounded-sm"
style={{ top: 0, right: 0, width: 11, height: 11, background: "var(--ms-purple-500)" }}
/>
<span
className="absolute rounded-sm"
style={{
bottom: 0,
right: 0,
width: 11,
height: 11,
background: "var(--ms-teal-500)",
}}
/>
<span
className="absolute rounded-sm"
style={{
bottom: 0,
left: 0,
width: 11,
height: 11,
background: "var(--ms-amber-500)",
}}
/>
<span
className="absolute rounded-full"
style={{
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: 8,
height: 8,
background: "var(--ms-pink-500)",
}}
/>
</div>
<span
className="text-sm font-bold"
style={{
background: "linear-gradient(135deg, var(--ms-blue-400), var(--ms-purple-500))",
backgroundClip: "text",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
letterSpacing: "-0.02em",
}}
>
Mosaic Stack
</span>
</Link>
{/* ── Breadcrumb ── */}
<nav
aria-label="Breadcrumb"
className="hidden sm:flex items-center"
style={{ fontSize: "0.8rem", color: "var(--text-2)", marginLeft: 4 }}
>
{breadcrumbSegments.length === 0 ? (
<span>Dashboard</span>
) : (
breadcrumbSegments.map((seg, idx) => (
<span key={idx} className="flex items-center gap-1">
{idx > 0 && <span style={{ color: "var(--muted)", margin: "0 2px" }}>/</span>}
<span
style={{
color: idx === breadcrumbSegments.length - 1 ? "var(--text-2)" : "var(--muted)",
}}
>
{seg}
</span>
</span>
))
)}
</nav>
{/* ── Search Bar ── */}
<div
className="hidden md:flex items-center"
style={{
flex: 1,
maxWidth: 340,
marginLeft: 16,
gap: 8,
background: "var(--surface)",
border: `1px solid ${searchFocused ? "var(--primary)" : "var(--border)"}`,
borderRadius: 6,
padding: "7px 12px",
transition: "border-color 0.15s",
}}
>
{/* Search icon */}
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ color: "var(--muted)", flexShrink: 0 }}
aria-hidden="true"
>
<circle cx="7" cy="7" r="5" />
<path d="M11 11l3 3" />
</svg>
<input
type="text"
placeholder="Search projects, agents, tasks… (⌘K)"
onFocus={() => {
setSearchFocused(true);
}}
onBlur={() => {
setSearchFocused(false);
}}
style={{
flex: 1,
background: "none",
border: "none",
outline: "none",
color: "var(--text)",
fontSize: "0.83rem",
fontFamily: "inherit",
}}
aria-label="Search projects, agents, tasks"
/>
</div>
{/* ── Spacer ── */}
<div style={{ flex: 1 }} />
{/* ── Right side controls ── */}
<div className="flex items-center" style={{ gap: 8 }}>
{/* System Status */}
<div
className="hidden lg:flex items-center"
style={{
gap: 7,
padding: "5px 10px",
borderRadius: 6,
background: "var(--surface)",
border: "1px solid var(--border)",
fontSize: "0.75rem",
fontFamily: "var(--mono)",
}}
aria-label="System status: All Systems Operational"
>
<div
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: "var(--success)",
boxShadow: "0 0 5px var(--success)",
flexShrink: 0,
}}
aria-hidden="true"
/>
<span style={{ color: "var(--muted)" }}>All Systems</span>
<span style={{ color: "var(--success)" }}>Operational</span>
</div>
{/* Terminal Toggle */}
<TerminalToggleButton />
{/* Notifications */}
<button
title="Notifications"
aria-label="Notifications (1 unread)"
style={{
width: 34,
height: 34,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--muted)",
position: "relative",
background: "none",
border: "none",
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "none";
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
}}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M8 1a5 5 0 0 1 5 5v2l1 2H2l1-2V6a5 5 0 0 1 5-5z" />
<path d="M6 13a2 2 0 0 0 4 0" />
</svg>
{/* Notification badge */}
<div
aria-hidden="true"
style={{
position: "absolute",
top: 4,
right: 4,
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--danger)",
border: "2px solid var(--bg-deep)",
}}
/>
</button>
{/* Theme Toggle */}
<ThemeToggle />
{/* User Avatar + Dropdown */}
<div ref={dropdownRef} style={{ position: "relative", flexShrink: 0 }}>
<button
onClick={() => {
setDropdownOpen((prev) => !prev);
}}
aria-label="Open user menu"
aria-expanded={dropdownOpen}
aria-haspopup="menu"
style={{
width: 30,
height: 30,
borderRadius: "50%",
background: user?.image
? "none"
: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
border: "none",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 0,
flexShrink: 0,
overflow: "hidden",
}}
>
{user?.image ? (
<img
src={user.image}
alt={user.name || user.email || "User avatar"}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<span
style={{
fontSize: "0.65rem",
fontWeight: 700,
color: "#fff",
letterSpacing: "0.02em",
lineHeight: 1,
}}
>
{initials}
</span>
)}
</button>
{/* Dropdown Menu */}
{dropdownOpen && (
<div
role="menu"
aria-label="User menu"
style={{
position: "absolute",
top: "calc(100% + 8px)",
right: 0,
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: 8,
padding: 6,
minWidth: 200,
boxShadow: "0 8px 32px rgba(0,0,0,0.3)",
zIndex: 200,
}}
>
{/* User info header */}
<div
style={{
padding: "8px 12px",
borderRadius: 6,
marginBottom: 2,
}}
>
<div
style={{
fontSize: "0.83rem",
fontWeight: 600,
color: "var(--text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{user?.name ?? "User"}
</div>
{user?.email && (
<div
style={{
fontSize: "0.75rem",
color: "var(--muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 2,
}}
>
{user.email}
</div>
)}
</div>
{/* Divider */}
<div
aria-hidden="true"
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
/>
{/* Profile link */}
<DropdownItem
href="/profile"
onClick={() => {
setDropdownOpen(false);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
</svg>
Profile
</DropdownItem>
{/* Account Settings link */}
<DropdownItem
href="/settings"
onClick={() => {
setDropdownOpen(false);
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2.5" />
<path d="M8 1v1.5M8 13.5V15M1 8h1.5M13.5 8H15M3.05 3.05l1.06 1.06M11.89 11.89l1.06 1.06M3.05 12.95l1.06-1.06M11.89 4.11l1.06-1.06" />
</svg>
Account Settings
</DropdownItem>
{/* Divider */}
<div
aria-hidden="true"
style={{ height: 1, background: "var(--border)", margin: "4px 0" }}
/>
{/* Sign Out */}
<button
role="menuitem"
onClick={() => {
setDropdownOpen(false);
void signOut();
}}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 6,
fontSize: "0.83rem",
cursor: "pointer",
background: "none",
border: "none",
color: "var(--danger)",
textAlign: "left",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface-2)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "none";
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3M10 11l4-4-4-4M14 8H6" />
</svg>
Sign Out
</button>
</div>
)}
</div>
</div>
</header>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
/** Terminal toggle button — visual only; no panel wired yet. */
function TerminalToggleButton(): React.JSX.Element {
const [hovered, setHovered] = useState(false);
return (
<button
title="Toggle terminal"
aria-label="Toggle terminal panel"
className="hidden lg:flex items-center"
style={{
gap: 6,
padding: "5px 10px",
borderRadius: 6,
background: "var(--surface)",
border: `1px solid ${hovered ? "var(--success)" : "var(--border)"}`,
fontSize: "0.75rem",
fontFamily: "var(--mono)",
color: hovered ? "var(--success)" : "var(--text-2)",
cursor: "pointer",
transition: "border-color 0.15s, color 0.15s",
flexShrink: 0,
}}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l3 3-3 3M9 12h3" />
</svg>
Terminal
</button>
);
}
interface DropdownItemProps {
href: string;
onClick: () => void;
children: React.ReactNode;
}
/** A navigation link styled as a dropdown menu item. */
function DropdownItem({ href, onClick, children }: DropdownItemProps): React.JSX.Element {
const [hovered, setHovered] = useState(false);
return (
<Link
href={href}
role="menuitem"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 12px",
borderRadius: 6,
fontSize: "0.83rem",
cursor: "pointer",
color: "var(--text-2)",
textDecoration: "none",
background: hovered ? "var(--surface-2)" : "none",
transition: "background 0.1s",
}}
onMouseEnter={() => {
setHovered(true);
}}
onMouseLeave={() => {
setHovered(false);
}}
>
{children}
</Link>
);
}

View File

@@ -0,0 +1,731 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import Image from "next/image";
import { useAuth } from "@/lib/auth/auth-context";
import { useSidebar } from "./SidebarContext";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NavBadge {
label: string;
pulse?: boolean;
}
interface NavItemConfig {
href: string;
label: string;
icon: React.JSX.Element;
badge?: NavBadge;
}
interface NavGroup {
label: string;
items: NavItemConfig[];
}
// ---------------------------------------------------------------------------
// SVG Icons (16x16 viewBox, stroke="currentColor", strokeWidth="1.5")
// ---------------------------------------------------------------------------
function IconDashboard(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
);
}
function IconProjects(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="4" width="14" height="10" rx="1.5" />
<path d="M1 7h14" />
<path d="M5 4V2.5A.5.5 0 0 1 5.5 2h5a.5.5 0 0 1 .5.5V4" />
</svg>
);
}
function IconProjectWorkspace(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="4" r="2" />
<circle cx="3" cy="12" r="2" />
<circle cx="13" cy="12" r="2" />
<path d="M8 6v2M5 12h6M6 8l-2 2M10 8l2 2" />
</svg>
);
}
function IconKanban(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="4" height="12" rx="1" />
<rect x="6" y="2" width="4" height="12" rx="1" />
<rect x="11" y="2" width="4" height="12" rx="1" />
</svg>
);
}
function IconFileManager(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M2 3.5A1.5 1.5 0 0 1 3.5 2h4l2 2h3A1.5 1.5 0 0 1 14 5.5v7A1.5 1.5 0 0 1 12.5 14h-9A1.5 1.5 0 0 1 2 12.5v-9z" />
</svg>
);
}
function IconLogs(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<path d="M2 4h12M2 8h8M2 12h10" />
</svg>
);
}
function IconTerminal(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<rect x="1" y="2" width="14" height="12" rx="1.5" />
<path d="M4 6l3 3-3 3M9 12h3" />
</svg>
);
}
function IconSettings(): React.JSX.Element {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="2.5" />
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
</svg>
);
}
function IconChevronLeft(): React.JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<path d="M10 3L5 8l5 5" />
</svg>
);
}
function IconChevronRight(): React.JSX.Element {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
aria-hidden="true"
>
<path d="M6 3l5 5-5 5" />
</svg>
);
}
// ---------------------------------------------------------------------------
// Nav groups definition
// ---------------------------------------------------------------------------
const NAV_GROUPS: NavGroup[] = [
{
label: "Overview",
items: [
{
href: "/",
label: "Dashboard",
icon: <IconDashboard />,
badge: { label: "live", pulse: true },
},
],
},
{
label: "Workspace",
items: [
{
href: "/projects",
label: "Projects",
icon: <IconProjects />,
},
{
href: "/workspace",
label: "Project Workspace",
icon: <IconProjectWorkspace />,
},
{
href: "/kanban",
label: "Kanban",
icon: <IconKanban />,
},
{
href: "/files",
label: "File Manager",
icon: <IconFileManager />,
},
],
},
{
label: "Operations",
items: [
{
href: "/logs",
label: "Logs & Telemetry",
icon: <IconLogs />,
badge: { label: "live", pulse: true },
},
{
href: "#terminal",
label: "Terminal",
icon: <IconTerminal />,
},
],
},
{
label: "System",
items: [
{
href: "/settings",
label: "Settings",
icon: <IconSettings />,
},
],
},
];
// ---------------------------------------------------------------------------
// Helper: derive initials from display name
// ---------------------------------------------------------------------------
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
const first = parts[0] ?? "";
if (parts.length === 1) {
return first.slice(0, 2).toUpperCase();
}
const last = parts[parts.length - 1] ?? "";
return ((first[0] ?? "") + (last[0] ?? "")).toUpperCase();
}
// ---------------------------------------------------------------------------
// NavBadge component
// ---------------------------------------------------------------------------
interface NavBadgeProps {
badge: NavBadge;
}
function NavBadgeChip({ badge }: NavBadgeProps): React.JSX.Element {
const pulseStyle: React.CSSProperties = badge.pulse
? {
background: "rgba(47,128,255,0.15)",
color: "var(--primary-l)",
}
: {
background: "var(--surface-2)",
color: "var(--muted)",
};
return (
<span
style={{
marginLeft: "auto",
fontSize: "0.68rem",
fontFamily: "var(--mono)",
padding: "1px 6px",
borderRadius: "10px",
display: "inline-flex",
alignItems: "center",
gap: "4px",
flexShrink: 0,
...pulseStyle,
}}
aria-label={badge.pulse ? `${badge.label} indicator` : badge.label}
>
{badge.pulse && (
<span
style={{
display: "inline-block",
width: "5px",
height: "5px",
borderRadius: "50%",
background: "var(--primary-l)",
boxShadow: "0 0 4px var(--primary)",
animation: "pulse 2s cubic-bezier(0.4,0,0.6,1) infinite",
}}
aria-hidden="true"
/>
)}
{badge.label}
</span>
);
}
// ---------------------------------------------------------------------------
// NavItem component
// ---------------------------------------------------------------------------
interface NavItemProps {
item: NavItemConfig;
isActive: boolean;
collapsed: boolean;
}
function NavItem({ item, isActive, collapsed }: NavItemProps): React.JSX.Element {
const baseStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: "11px",
padding: "9px 10px",
borderRadius: "6px",
fontSize: "0.875rem",
fontWeight: 500,
color: isActive ? "var(--text)" : "var(--muted)",
background: isActive ? "var(--surface)" : "transparent",
position: "relative",
transition: "background 0.12s ease, color 0.12s ease",
textDecoration: "none",
justifyContent: collapsed ? "center" : undefined,
whiteSpace: "nowrap",
overflow: "hidden",
};
const iconStyle: React.CSSProperties = {
width: "16px",
height: "16px",
flexShrink: 0,
opacity: isActive ? 1 : 0.7,
transition: "opacity 0.12s ease",
};
const content = (
<>
{/* Active left accent bar */}
{isActive && (
<span
style={{
position: "absolute",
left: 0,
top: "6px",
bottom: "6px",
width: "3px",
background: "var(--primary)",
borderRadius: "0 2px 2px 0",
}}
aria-hidden="true"
/>
)}
{/* Icon */}
<span style={iconStyle}>{item.icon}</span>
{/* Label and badge — hidden when collapsed */}
{!collapsed && (
<>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis" }}>
{item.label}
</span>
{item.badge !== undefined && <NavBadgeChip badge={item.badge} />}
</>
)}
</>
);
const sharedProps = {
style: baseStyle,
"aria-current": isActive ? ("page" as const) : undefined,
title: collapsed ? item.label : undefined,
onMouseEnter: (e: React.MouseEvent<HTMLElement>): void => {
if (!isActive) {
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
"[data-nav-icon]"
);
if (iconEl) iconEl.style.opacity = "1";
}
},
onMouseLeave: (e: React.MouseEvent<HTMLElement>): void => {
if (!isActive) {
(e.currentTarget as HTMLElement).style.background = "transparent";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
const iconEl = (e.currentTarget as HTMLElement).querySelector<HTMLElement>(
"[data-nav-icon]"
);
if (iconEl) iconEl.style.opacity = "0.7";
}
},
};
if (item.href.startsWith("#")) {
return (
<a href={item.href} {...sharedProps}>
{content}
</a>
);
}
return (
<Link href={item.href} {...sharedProps}>
{content}
</Link>
);
}
// ---------------------------------------------------------------------------
// UserCard component
// ---------------------------------------------------------------------------
interface UserCardProps {
collapsed: boolean;
}
function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
const { user } = useAuth();
const displayName = user?.name ?? "User";
const initials = getInitials(displayName);
const role = user?.workspaceRole ?? "Member";
return (
<footer
style={{
padding: "12px 10px",
borderTop: "1px solid var(--border)",
flexShrink: 0,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "8px 10px",
borderRadius: "6px",
cursor: "pointer",
transition: "background 0.12s ease",
justifyContent: collapsed ? "center" : undefined,
}}
role="button"
tabIndex={0}
title={collapsed ? `${displayName}${role}` : undefined}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
onKeyDown={(e): void => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
}
}}
aria-label={`User: ${displayName}, Role: ${role}`}
>
{/* Avatar */}
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500))",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.75rem",
fontWeight: 700,
color: "#fff",
flexShrink: 0,
overflow: "hidden",
}}
>
{user?.image ? (
<Image
src={user.image}
alt={`${displayName} avatar`}
width={30}
height={30}
style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "50%" }}
/>
) : (
<span aria-hidden="true">{initials}</span>
)}
</div>
{/* Name and role — hidden when collapsed */}
{!collapsed && (
<>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.83rem",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--text)",
}}
>
{displayName}
</div>
<div
style={{
fontSize: "0.72rem",
color: "var(--muted)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{role}
</div>
</div>
{/* Online status dot */}
<div
style={{
marginLeft: "auto",
width: "7px",
height: "7px",
borderRadius: "50%",
background: "var(--success)",
boxShadow: "0 0 6px var(--success)",
flexShrink: 0,
}}
aria-label="Online"
role="img"
/>
</>
)}
</div>
</footer>
);
}
// ---------------------------------------------------------------------------
// CollapseToggle component
// ---------------------------------------------------------------------------
interface CollapseToggleProps {
collapsed: boolean;
onToggle: () => void;
}
function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX.Element {
return (
<div
style={{
padding: "4px 10px 8px",
display: "flex",
justifyContent: collapsed ? "center" : "flex-end",
}}
>
<button
type="button"
onClick={onToggle}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "26px",
height: "26px",
borderRadius: "6px",
color: "var(--muted)",
transition: "background 0.12s ease, color 0.12s ease",
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e): void => {
(e.currentTarget as HTMLElement).style.background = "var(--surface)";
(e.currentTarget as HTMLElement).style.color = "var(--text-2)";
}}
onMouseLeave={(e): void => {
(e.currentTarget as HTMLElement).style.background = "transparent";
(e.currentTarget as HTMLElement).style.color = "var(--muted)";
}}
>
{collapsed ? <IconChevronRight /> : <IconChevronLeft />}
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Main AppSidebar component
// ---------------------------------------------------------------------------
/**
* Application sidebar — navigation groups, collapse toggle, and user card.
* Logo lives in AppHeader per MS15 design spec.
*/
export function AppSidebar(): React.JSX.Element {
const pathname = usePathname();
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
return (
<>
{/* Mobile backdrop — rendered behind the sidebar when open on mobile */}
{isMobile && mobileOpen && (
<div
aria-hidden="true"
onClick={() => {
setMobileOpen(false);
}}
style={{
position: "fixed",
inset: 0,
top: "var(--topbar-h)",
background: "rgba(0,0,0,0.5)",
zIndex: 140,
}}
/>
)}
<aside
id="app-sidebar"
className="app-sidebar"
data-collapsed={collapsed ? "true" : undefined}
data-mobile-open={mobileOpen ? "true" : undefined}
aria-label="Application navigation"
>
{/* Sidebar body — scrollable nav area */}
<nav
style={{
flex: 1,
overflowY: "auto",
overflowX: "hidden",
padding: "10px 10px",
}}
aria-label="Main navigation"
>
{NAV_GROUPS.map((group) => (
<div key={group.label} style={{ marginBottom: "18px" }}>
{/* Group label — hidden when collapsed */}
{!collapsed && (
<p
style={{
fontSize: "0.67rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.09em",
color: "var(--muted)",
padding: "0 10px",
marginBottom: "4px",
userSelect: "none",
}}
>
{group.label}
</p>
)}
{/* Nav items */}
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{group.items.map((item) => {
const isActive =
item.href === "/"
? pathname === "/"
: item.href.startsWith("#")
? false
: pathname === item.href || pathname.startsWith(item.href + "/");
return (
<li key={item.href}>
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
</li>
);
})}
</ul>
</div>
))}
{/* Collapse toggle — anchored at bottom of nav */}
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
</nav>
{/* User card footer */}
<UserCard collapsed={collapsed} />
</aside>
</>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
interface SidebarContextValue {
collapsed: boolean;
toggleCollapsed: () => void;
mobileOpen: boolean;
setMobileOpen: (open: boolean) => void;
isMobile: boolean;
}
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
/** Breakpoint below which we treat the viewport as "mobile" (matches CSS max-width: 767px). */
const MOBILE_MAX_WIDTH = 767;
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// Initialise and track mobile breakpoint using matchMedia
useEffect((): (() => void) => {
const mql = window.matchMedia(`(max-width: ${String(MOBILE_MAX_WIDTH)}px)`);
const handleChange = (e: MediaQueryListEvent): void => {
setIsMobile(e.matches);
// Close mobile sidebar when viewport grows out of mobile range
if (!e.matches) {
setMobileOpen(false);
}
};
// Set initial value synchronously
setIsMobile(mql.matches);
mql.addEventListener("change", handleChange);
return (): void => {
mql.removeEventListener("change", handleChange);
};
}, []);
const toggleCollapsed = useCallback((): void => {
setCollapsed((prev) => !prev);
}, []);
const value: SidebarContextValue = {
collapsed,
toggleCollapsed,
mobileOpen,
setMobileOpen,
isMobile,
};
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
}
export function useSidebar(): SidebarContextValue {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error("useSidebar must be used within SidebarProvider");
}
return context;
}

View File

@@ -20,7 +20,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
// Sun icon for dark mode (click to switch to light)
<svg
className="h-5 w-5"
style={{ color: "rgb(var(--semantic-warning))" }}
style={{ color: "var(--warn)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -33,7 +33,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
// Moon icon for light mode (click to switch to dark)
<svg
className="h-5 w-5"
style={{ color: "rgb(var(--text-secondary))" }}
style={{ color: "var(--text-2)" }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"

View File

@@ -0,0 +1,100 @@
"use client";
import type { CSSProperties } from "react";
export interface MosaicLogoProps {
/** Width and height in pixels (default: 36) */
size?: number;
/** Whether to animate rotation (default: false) */
spinning?: boolean;
/** Seconds for one full rotation (default: 20) */
spinDuration?: number;
/** Additional CSS classes for the root element */
className?: string;
}
/**
* MosaicLogo renders the 5-element Mosaic logo icon:
* - 4 corner squares (blue, purple, teal, amber)
* - 1 center circle (pink)
*
* Colors use CSS custom properties so they respond to theme changes.
* When `spinning` is true the logo rotates continuously, making it
* suitable for use as a loading indicator.
*/
export function MosaicLogo({
size = 36,
spinning = false,
spinDuration = 20,
className = "",
}: MosaicLogoProps): React.JSX.Element {
// Scale factor relative to the 36px reference design
const scale = size / 36;
// Derived dimensions
const squareSize = Math.round(14 * scale);
const circleSize = Math.round(11 * scale);
const borderRadius = Math.round(3 * scale);
const animationValue = spinning
? `mosaicLogoSpin ${String(spinDuration)}s linear infinite`
: undefined;
const containerStyle: CSSProperties = {
width: size,
height: size,
position: "relative",
flexShrink: 0,
animation: animationValue,
transformOrigin: "center",
};
const baseSquareStyle: CSSProperties = {
position: "absolute",
width: squareSize,
height: squareSize,
borderRadius,
};
const circleStyle: CSSProperties = {
position: "absolute",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
width: circleSize,
height: circleSize,
borderRadius: "50%",
background: "var(--ms-pink-500)",
};
return (
<>
{spinning && (
<style>{`
@keyframes mosaicLogoSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
)}
<div style={containerStyle} className={className} role="img" aria-label="Mosaic logo">
{/* Top-left: blue */}
<div style={{ ...baseSquareStyle, top: 0, left: 0, background: "var(--ms-blue-500)" }} />
{/* Top-right: purple */}
<div style={{ ...baseSquareStyle, top: 0, right: 0, background: "var(--ms-purple-500)" }} />
{/* Bottom-right: teal */}
<div
style={{ ...baseSquareStyle, bottom: 0, right: 0, background: "var(--ms-teal-500)" }}
/>
{/* Bottom-left: amber */}
<div
style={{ ...baseSquareStyle, bottom: 0, left: 0, background: "var(--ms-amber-500)" }}
/>
{/* Center: pink circle */}
<div style={circleStyle} />
</div>
</>
);
}
export default MosaicLogo;

View File

@@ -0,0 +1,49 @@
"use client";
import { MosaicLogo } from "./MosaicLogo";
import type { ReactElement } from "react";
export interface MosaicSpinnerProps {
/** Width and height of the logo in pixels (default: 36) */
size?: number;
/** Seconds for one full rotation (default: 20) */
spinDuration?: number;
/** Optional text label displayed below the spinner */
label?: string;
/**
* When true, wraps the spinner in a full-page centered overlay.
* When false (default), renders inline.
*/
fullPage?: boolean;
/** Additional CSS classes for the wrapper element */
className?: string;
}
/**
* MosaicSpinner wraps MosaicLogo with spinning enabled.
* It can be used as a full-page loading overlay or as an inline indicator.
*/
export function MosaicSpinner({
size = 36,
spinDuration = 20,
label,
fullPage = false,
className = "",
}: MosaicSpinnerProps): ReactElement {
const inner = (
<div className={`flex flex-col items-center gap-3 ${className}`}>
<MosaicLogo size={size} spinning spinDuration={spinDuration} />
{label !== undefined && label !== "" && (
<span className="text-sm text-gray-500">{label}</span>
)}
</div>
);
if (fullPage) {
return <div className="flex min-h-screen items-center justify-center">{inner}</div>;
}
return inner;
}
export default MosaicSpinner;