feat(web): design system — ms-* tokens, ThemeProvider, MosaicLogo, sidebar (#221)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci 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 #221.
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { SidebarProvider, useSidebar } from './sidebar-context';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { Topbar } from './topbar';
|
||||
|
||||
@@ -6,14 +9,24 @@ interface AppShellProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps): React.ReactElement {
|
||||
function AppShellFrame({ children }: AppShellProps): React.ReactElement {
|
||||
const { collapsed, isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="app-shell" data-sidebar-hidden={!isMobile && collapsed ? 'true' : undefined}>
|
||||
<Topbar />
|
||||
<Sidebar />
|
||||
<div className="pl-sidebar">
|
||||
<Topbar />
|
||||
<main className="p-6">{children}</main>
|
||||
<div className="app-main">
|
||||
<main className="h-full overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps): React.ReactElement {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppShellFrame>{children}</AppShellFrame>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
67
apps/web/src/components/layout/sidebar-context.tsx
Normal file
67
apps/web/src/components/layout/sidebar-context.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
interface SidebarContextValue {
|
||||
collapsed: boolean;
|
||||
toggleCollapsed: () => void;
|
||||
mobileOpen: boolean;
|
||||
setMobileOpen: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
const MOBILE_MAX_WIDTH = 767;
|
||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${String(MOBILE_MAX_WIDTH)}px)`);
|
||||
|
||||
const syncState = (matches: boolean): void => {
|
||||
setIsMobile(matches);
|
||||
if (!matches) {
|
||||
setMobileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
syncState(mediaQuery.matches);
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent): void => {
|
||||
syncState(event.matches);
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider
|
||||
value={{
|
||||
collapsed,
|
||||
toggleCollapsed: () => setCollapsed((value) => !value),
|
||||
mobileOpen,
|
||||
setMobileOpen,
|
||||
isMobile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar(): SidebarContextValue {
|
||||
const context = useContext(SidebarContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useSidebar must be used within SidebarProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -3,58 +3,178 @@
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { MosaicLogo } from '@/components/ui/mosaic-logo';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
icon: React.JSX.Element;
|
||||
}
|
||||
|
||||
function IconChat(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M3 4.5A2.5 2.5 0 0 1 5.5 2h5A2.5 2.5 0 0 1 13 4.5v3A2.5 2.5 0 0 1 10.5 10H8l-3.5 3v-3H5.5A2.5 2.5 0 0 1 3 7.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconTasks(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M6 3h7M6 8h7M6 13h7" />
|
||||
<path d="M2.5 3.5 3.5 4.5 5 2.5M2.5 8.5 3.5 9.5 5 7.5M2.5 13.5 3.5 14.5 5 12.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconProjects(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M2 4.5A1.5 1.5 0 0 1 3.5 3h3l1.5 1.5h4A1.5 1.5 0 0 1 13.5 6v5.5A1.5 1.5 0 0 1 12 13H3.5A1.5 1.5 0 0 1 2 11.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconSettings(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<circle cx="8" cy="8" r="2.25" />
|
||||
<path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.05 3.05l1.4 1.4M11.55 11.55l1.4 1.4M3.05 12.95l1.4-1.4M11.55 4.45l1.4-1.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconAdmin(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M8 1.75 13 3.5v3.58c0 3.12-1.88 5.94-5 7.17-3.12-1.23-5-4.05-5-7.17V3.5z" />
|
||||
<path d="M6.25 7.75 7.5 9l2.5-2.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ label: 'Chat', href: '/chat', icon: '💬' },
|
||||
{ label: 'Tasks', href: '/tasks', icon: '📋' },
|
||||
{ label: 'Projects', href: '/projects', icon: '📁' },
|
||||
{ label: 'Settings', href: '/settings', icon: '⚙️' },
|
||||
{ label: 'Admin', href: '/admin', icon: '🛡️' },
|
||||
{ label: 'Chat', href: '/chat', icon: <IconChat /> },
|
||||
{ label: 'Tasks', href: '/tasks', icon: <IconTasks /> },
|
||||
{ label: 'Projects', href: '/projects', icon: <IconProjects /> },
|
||||
{ label: 'Settings', href: '/settings', icon: <IconSettings /> },
|
||||
{ label: 'Admin', href: '/admin', icon: <IconAdmin /> },
|
||||
];
|
||||
|
||||
export function Sidebar(): React.ReactElement {
|
||||
const pathname = usePathname();
|
||||
const { mobileOpen, setMobileOpen } = useSidebar();
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-30 flex h-screen w-sidebar flex-col border-r border-surface-border bg-surface-card">
|
||||
<div className="flex h-14 items-center px-4">
|
||||
<Link href="/" className="text-lg font-semibold text-text-primary">
|
||||
Mosaic
|
||||
</Link>
|
||||
</div>
|
||||
<>
|
||||
<aside
|
||||
className="app-sidebar"
|
||||
data-mobile-open={mobileOpen ? 'true' : undefined}
|
||||
style={{
|
||||
width: 'var(--sidebar-w)',
|
||||
background: 'var(--surface)',
|
||||
borderRightColor: 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex h-16 items-center gap-3 border-b px-5"
|
||||
style={{ borderColor: 'var(--border)' }}
|
||||
>
|
||||
<MosaicLogo size={32} />
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--text)]">
|
||||
Mosaic
|
||||
</span>
|
||||
<span className="text-xs text-[var(--muted)]">Mission Control</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-2 py-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
isActive
|
||||
? 'bg-blue-600/20 text-blue-400'
|
||||
: 'text-text-secondary hover:bg-surface-elevated hover:text-text-primary',
|
||||
)}
|
||||
>
|
||||
<span className="text-base" aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<nav className="flex-1 px-3 py-4">
|
||||
<div className="mb-3 px-2 text-[11px] font-medium uppercase tracking-[0.18em] text-[var(--muted)]">
|
||||
Workspace
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
|
||||
<div className="border-t border-surface-border p-4">
|
||||
<p className="text-xs text-text-muted">Mosaic Stack v0.0.4</p>
|
||||
</div>
|
||||
</aside>
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all duration-150',
|
||||
isActive ? 'font-medium' : 'hover:bg-white/4',
|
||||
)}
|
||||
style={
|
||||
isActive
|
||||
? {
|
||||
background: 'color-mix(in srgb, var(--primary) 18%, transparent)',
|
||||
color: 'var(--primary)',
|
||||
}
|
||||
: { color: 'var(--text-2)' }
|
||||
}
|
||||
>
|
||||
<span className="shrink-0" aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="border-t px-5 py-4" style={{ borderColor: 'var(--border)' }}>
|
||||
<p className="text-xs text-[var(--muted)]">Mosaic Stack v0.0.4</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{mobileOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close sidebar"
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
46
apps/web/src/components/layout/theme-toggle.tsx
Normal file
46
apps/web/src/components/layout/theme-toggle.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useTheme } from '@/providers/theme-provider';
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className = '' }: ThemeToggleProps): React.JSX.Element {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
className={`btn-ghost rounded-md p-2 ${className}`}
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: 'var(--warn)' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: 'var(--text-2)' }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useSession, signOut } from '@/lib/auth-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { signOut, useSession } from '@/lib/auth-client';
|
||||
import { ThemeToggle } from './theme-toggle';
|
||||
import { useSidebar } from './sidebar-context';
|
||||
|
||||
function MenuIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M2 4h12M2 8h12M2 12h12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function Topbar(): React.ReactElement {
|
||||
const { data: session } = useSession();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (pathname.startsWith('/chat')) {
|
||||
return <></>;
|
||||
}
|
||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||
|
||||
async function handleSignOut(): Promise<void> {
|
||||
await signOut();
|
||||
router.replace('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-14 items-center justify-between border-b border-surface-border bg-surface-card/80 px-6 backdrop-blur-sm">
|
||||
<div />
|
||||
function handleSidebarToggle(): void {
|
||||
if (isMobile) {
|
||||
setMobileOpen(!mobileOpen);
|
||||
return;
|
||||
}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
toggleCollapsed();
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className="app-header justify-between border-b px-4 md:px-6"
|
||||
style={{
|
||||
height: 'var(--topbar-h)',
|
||||
background: 'color-mix(in srgb, var(--surface) 88%, transparent)',
|
||||
borderBottomColor: 'var(--border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSidebarToggle}
|
||||
className="btn-ghost rounded-lg border p-2"
|
||||
style={{ borderColor: 'var(--border)', color: 'var(--text-2)' }}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
<div className="hidden sm:block">
|
||||
<div className="text-sm font-medium text-[var(--text)]">Workspace</div>
|
||||
<div className="text-xs text-[var(--muted)]">Unified agent operations</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
{session?.user ? (
|
||||
<>
|
||||
<span className="text-sm text-text-secondary">
|
||||
<span className="hidden text-sm text-[var(--text-2)] sm:block">
|
||||
{session.user.name ?? session.user.email}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
className="rounded-md px-3 py-1.5 text-sm text-text-muted transition-colors hover:bg-surface-elevated hover:text-text-primary"
|
||||
className="rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||
style={{ color: 'var(--muted)' }}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-text-muted">Not signed in</span>
|
||||
<span className="text-sm text-[var(--muted)]">Not signed in</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
89
apps/web/src/components/ui/mosaic-logo.tsx
Normal file
89
apps/web/src/components/ui/mosaic-logo.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export interface MosaicLogoProps {
|
||||
size?: number;
|
||||
spinning?: boolean;
|
||||
spinDuration?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MosaicLogo({
|
||||
size = 36,
|
||||
spinning = false,
|
||||
spinDuration = 20,
|
||||
className = '',
|
||||
}: MosaicLogoProps): React.JSX.Element {
|
||||
const scale = size / 36;
|
||||
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>
|
||||
) : null}
|
||||
<div style={containerStyle} className={className} role="img" aria-label="Mosaic logo">
|
||||
<div style={{ ...baseSquareStyle, top: 0, left: 0, background: 'var(--ms-blue-500)' }} />
|
||||
<div style={{ ...baseSquareStyle, top: 0, right: 0, background: 'var(--ms-purple-500)' }} />
|
||||
<div
|
||||
style={{
|
||||
...baseSquareStyle,
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
background: 'var(--ms-teal-500)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
...baseSquareStyle,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
background: 'var(--ms-amber-500)',
|
||||
}}
|
||||
/>
|
||||
<div style={circleStyle} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default MosaicLogo;
|
||||
Reference in New Issue
Block a user