feat(web): design system — ms-* tokens, ThemeProvider, MosaicLogo, sidebar (#221)
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:
2026-03-21 12:57:24 +00:00
committed by jason.woltje
parent dd108b9ab4
commit de64695ac5
9 changed files with 784 additions and 160 deletions

View File

@@ -1,101 +1,186 @@
@import 'tailwindcss';
/*
* Mosaic Stack design tokens mapped to Tailwind v4 theme.
* Source: @mosaic/design-tokens (AD-13)
* Fonts: Outfit (sans), Fira Code (mono)
* Palette: deep blue-grays + blue/purple/teal accents
* Default: dark theme
*/
/* =============================================================================
MOSAIC DESIGN SYSTEM — Reference token system from dashboard design
============================================================================= */
@theme {
/* ─── Fonts ─── */
--font-sans: 'Outfit', system-ui, -apple-system, sans-serif;
--font-mono: 'Fira Code', ui-monospace, Menlo, monospace;
/* -----------------------------------------------------------------------------
Primitive Tokens (Dark-first — dark is the default theme)
----------------------------------------------------------------------------- */
:root {
/* Mosaic design tokens — dark palette (default) */
--ms-bg-950: #080b12;
--ms-bg-900: #0f141d;
--ms-bg-850: #151b26;
--ms-surface-800: #1b2331;
--ms-surface-750: #232d3f;
--ms-border-700: #2f3b52;
--ms-text-100: #eef3ff;
--ms-text-300: #c5d0e6;
--ms-text-500: #8f9db7;
--ms-blue-500: #2f80ff;
--ms-blue-400: #56a0ff;
--ms-red-500: #e5484d;
--ms-red-400: #f06a6f;
--ms-purple-500: #8b5cf6;
--ms-purple-400: #a78bfa;
--ms-teal-500: #14b8a6;
--ms-teal-400: #2dd4bf;
--ms-amber-500: #f59e0b;
--ms-amber-400: #fbbf24;
--ms-pink-500: #ec4899;
--ms-emerald-500: #10b981;
--ms-orange-500: #f97316;
--ms-cyan-500: #06b6d4;
--ms-indigo-500: #6366f1;
/* ─── Neutral blue-gray scale ─── */
--color-gray-50: #f0f2f5;
--color-gray-100: #dce0e8;
--color-gray-200: #b8c0cc;
--color-gray-300: #8e99a9;
--color-gray-400: #6b7a8d;
--color-gray-500: #4e5d70;
--color-gray-600: #3b4859;
--color-gray-700: #2a3544;
--color-gray-800: #1c2433;
--color-gray-900: #111827;
--color-gray-950: #0a0f1a;
/* Semantic aliases — dark theme is default */
--bg: var(--ms-bg-900);
--bg-deep: var(--ms-bg-950);
--bg-mid: var(--ms-bg-850);
--surface: var(--ms-surface-800);
--surface-2: var(--ms-surface-750);
--border: var(--ms-border-700);
--text: var(--ms-text-100);
--text-2: var(--ms-text-300);
--muted: var(--ms-text-500);
--primary: var(--ms-blue-500);
--primary-l: var(--ms-blue-400);
--danger: var(--ms-red-500);
--success: var(--ms-teal-500);
--warn: var(--ms-amber-500);
--purple: var(--ms-purple-500);
/* ─── Primary — blue ─── */
--color-blue-50: #eff4ff;
--color-blue-100: #dae5ff;
--color-blue-200: #bdd1ff;
--color-blue-300: #8fb4ff;
--color-blue-400: #5b8bff;
--color-blue-500: #3b6cf7;
--color-blue-600: #2551e0;
--color-blue-700: #1d40c0;
--color-blue-800: #1e369c;
--color-blue-900: #1e317b;
--color-blue-950: #162050;
/* Typography */
--font: var(--font-outfit, 'Outfit'), system-ui, sans-serif;
--mono: var(--font-fira-code, 'Fira Code'), 'Cascadia Code', monospace;
/* ─── Accent — purple ─── */
--color-purple-50: #f3f0ff;
--color-purple-100: #e7dfff;
--color-purple-200: #d2c3ff;
--color-purple-300: #b49aff;
--color-purple-400: #9466ff;
--color-purple-500: #7c3aed;
--color-purple-600: #6d28d9;
--color-purple-700: #5b21b6;
--color-purple-800: #4c1d95;
--color-purple-900: #3b1578;
--color-purple-950: #230d4d;
/* Radius scale */
--r: 8px;
--r-sm: 5px;
--r-lg: 12px;
--r-xl: 16px;
/* ─── Accent — teal ─── */
--color-teal-50: #effcf9;
--color-teal-100: #d0f7ef;
--color-teal-200: #a4eddf;
--color-teal-300: #6fddcb;
--color-teal-400: #3ec5b2;
--color-teal-500: #25aa99;
--color-teal-600: #1c897e;
--color-teal-700: #1b6e66;
--color-teal-800: #1a5853;
--color-teal-900: #194945;
--color-teal-950: #082d2b;
/* Layout dimensions */
--sidebar-w: 260px;
--topbar-h: 56px;
--terminal-h: 220px;
/* ─── Semantic surface tokens ─── */
--color-surface-bg: #0a0f1a;
--color-surface-card: #111827;
--color-surface-elevated: #1c2433;
--color-surface-border: #2a3544;
/* Easing */
--ease: cubic-bezier(0.16, 1, 0.3, 1);
/* ─── Semantic text tokens ─── */
--color-text-primary: #f0f2f5;
--color-text-secondary: #8e99a9;
--color-text-muted: #6b7a8d;
/* ─── Status colors ─── */
--color-success: #22c55e;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* ─── Sidebar width ─── */
--spacing-sidebar: 16rem;
/* Legacy shadow tokens (retained for component compat) */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4), 0 2px 4px -2px rgb(0 0 0 / 0.3);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
}
/* ─── Base styles ─── */
body {
background-color: var(--color-surface-bg);
color: var(--color-text-primary);
font-family: var(--font-sans);
[data-theme='light'] {
--ms-bg-950: #f8faff;
--ms-bg-900: #f0f4fc;
--ms-bg-850: #e8edf8;
--ms-surface-800: #dde4f2;
--ms-surface-750: #d0d9ec;
--ms-border-700: #b8c4de;
--ms-text-100: #0f141d;
--ms-text-300: #2f3b52;
--ms-text-500: #5a6a87;
--bg: var(--ms-bg-900);
--bg-deep: var(--ms-bg-950);
--bg-mid: var(--ms-bg-850);
--surface: var(--ms-surface-800);
--surface-2: var(--ms-surface-750);
--border: var(--ms-border-700);
--text: var(--ms-text-100);
--text-2: var(--ms-text-300);
--muted: var(--ms-text-500);
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05), 0 1px 3px 0 rgb(0 0 0 / 0.05);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.08), 0 2px 4px -2px rgb(0 0 0 / 0.06);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.08);
}
@theme {
--font-sans: var(--font);
--font-mono: var(--mono);
--color-surface-bg: var(--bg);
--color-surface-card: var(--surface);
--color-surface-elevated: var(--surface-2);
--color-surface-border: var(--border);
--color-text-primary: var(--text);
--color-text-secondary: var(--text-2);
--color-text-muted: var(--muted);
--color-accent: var(--primary);
--color-success: var(--success);
--color-warning: var(--warn);
--color-error: var(--danger);
--color-info: var(--primary-l);
--spacing-sidebar: var(--sidebar-w);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 15px;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ─── Scrollbar styling ─── */
body {
font-family: var(--font);
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input,
select,
textarea {
font-family: inherit;
}
ul {
list-style: none;
}
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='1'/%3E%3C/svg%3E");
opacity: 0.025;
}
@layer base {
:focus-visible {
outline: 2px solid var(--ms-blue-400);
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -106,10 +191,96 @@ body {
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-600);
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
background: var(--muted);
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.app-shell {
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
grid-template-rows: var(--topbar-h) 1fr;
height: 100vh;
overflow: hidden;
}
.app-header {
grid-column: 1 / -1;
grid-row: 1;
background: var(--bg-deep);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 12px;
z-index: 100;
}
.app-sidebar {
grid-column: 1;
grid-row: 2;
background: var(--bg-deep);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.app-main {
grid-column: 2;
grid-row: 2;
background: var(--bg);
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
@media (max-width: 767px) {
.app-shell {
grid-template-columns: 1fr;
}
.app-sidebar {
position: fixed;
left: 0;
top: var(--topbar-h);
bottom: 0;
width: 240px;
z-index: 150;
transform: translateX(-100%);
transition: transform 0.2s ease;
}
.app-sidebar[data-mobile-open='true'] {
transform: translateX(0);
}
.app-main,
.app-header {
grid-column: 1;
}
}
@media (min-width: 768px) and (max-width: 1023px) {
.app-shell[data-sidebar-hidden='true'] {
grid-template-columns: 1fr;
}
.app-shell[data-sidebar-hidden='true'] .app-sidebar {
display: none;
}
.app-shell[data-sidebar-hidden='true'] .app-main,
.app-shell[data-sidebar-hidden='true'] .app-header {
grid-column: 1;
}
}

View File

@@ -1,30 +1,41 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { Outfit, Fira_Code } from 'next/font/google';
import { ThemeProvider } from '@/providers/theme-provider';
import './globals.css';
const outfit = Outfit({
subsets: ['latin'],
variable: '--font-sans',
display: 'swap',
weight: ['300', '400', '500', '600', '700'],
});
const firaCode = Fira_Code({
subsets: ['latin'],
variable: '--font-mono',
display: 'swap',
weight: ['400', '500', '700'],
});
export const metadata = {
export const metadata: Metadata = {
title: 'Mosaic',
description: 'Mosaic Stack Dashboard',
};
function themeScript(): string {
return `
(function () {
try {
var theme = window.localStorage.getItem('mosaic-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme === 'light' ? 'light' : 'dark');
} catch (error) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
`;
}
export default function RootLayout({ children }: { children: ReactNode }): React.ReactElement {
return (
<html lang="en" className={`dark ${outfit.variable} ${firaCode.variable}`}>
<body>{children}</body>
<html lang="en" suppressHydrationWarning>
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Fira+Code:wght@400;500&display=swap"
/>
<script dangerouslySetInnerHTML={{ __html: themeScript() }} />
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}

View File

@@ -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>
);
}

View 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;
}

View File

@@ -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}
</>
);
}

View 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>
);
}

View File

@@ -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>

View 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;

View File

@@ -0,0 +1,62 @@
'use client';
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
export type Theme = 'dark' | 'light';
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const STORAGE_KEY = 'mosaic-theme';
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function getInitialTheme(): Theme {
if (typeof document === 'undefined') {
return 'dark';
}
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
export function ThemeProvider({ children }: { children: ReactNode }): React.JSX.Element {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
window.localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
useEffect(() => {
const storedTheme = window.localStorage.getItem(STORAGE_KEY);
if (storedTheme === 'light' || storedTheme === 'dark') {
setThemeState(storedTheme);
return;
}
document.documentElement.setAttribute('data-theme', 'dark');
}, []);
const value = useMemo<ThemeContextValue>(
() => ({
theme,
toggleTheme: () => setThemeState((current) => (current === 'dark' ? 'light' : 'dark')),
setTheme: (nextTheme) => setThemeState(nextTheme),
}),
[theme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextValue {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}