feat(web): add MosaicLogo and MosaicSpinner components (MS15-FE-006)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
- Add MosaicLogo component with 5-element Mosaic brand icon (4 corner squares + center circle), using CSS custom properties for theme-aware colors and optional rotation animation - Add MosaicSpinner wrapper that enables spinning and supports optional label and full-page overlay modes - Replace generic CSS spinner in authenticated layout loading state with MosaicSpinner for consistent brand identity Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,10 @@ import { useEffect } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
import { IS_MOCK_AUTH_MODE } from "@/lib/config";
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { AppHeader } from "@/components/layout/AppHeader";
|
||||||
|
import { AppSidebar } from "@/components/layout/AppSidebar";
|
||||||
import { ChatOverlay } from "@/components/chat";
|
import { ChatOverlay } from "@/components/chat";
|
||||||
|
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
export default function AuthenticatedLayout({
|
export default function AuthenticatedLayout({
|
||||||
@@ -23,11 +25,7 @@ export default function AuthenticatedLayout({
|
|||||||
}, [isAuthenticated, isLoading, router]);
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <MosaicSpinner size={48} fullPage />;
|
||||||
<div className="flex min-h-screen items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
@@ -35,19 +33,31 @@ export default function AuthenticatedLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="app-shell">
|
||||||
<Navigation />
|
{/* Full-width header — grid-column: 1 / -1 via .app-header CSS class */}
|
||||||
<div className="pt-16">
|
<AppHeader />
|
||||||
|
|
||||||
|
{/* Sidebar — left column, row 2, via .app-sidebar CSS class */}
|
||||||
|
<AppSidebar />
|
||||||
|
|
||||||
|
{/* Main content — right column, row 2, via .app-main CSS class */}
|
||||||
|
<main className="app-main" id="main-content">
|
||||||
{IS_MOCK_AUTH_MODE && (
|
{IS_MOCK_AUTH_MODE && (
|
||||||
<div
|
<div
|
||||||
className="border-b border-amber-300 bg-amber-50 px-4 py-2 text-sm text-amber-900"
|
className="border-b px-4 py-2 text-xs font-medium flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--ms-amber-500)",
|
||||||
|
background: "rgba(245, 158, 11, 0.08)",
|
||||||
|
color: "var(--ms-amber-400)",
|
||||||
|
}}
|
||||||
data-testid="mock-auth-banner"
|
data-testid="mock-auth-banner"
|
||||||
>
|
>
|
||||||
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
Mock Auth Mode (Local Only): Real authentication is bypassed for frontend development.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{children}
|
<div className="flex-1 overflow-y-auto p-5">{children}</div>
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
{!IS_MOCK_AUTH_MODE && <ChatOverlay />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,147 +3,250 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
DESIGN C: PROFESSIONAL/ENTERPRISE DESIGN SYSTEM
|
MOSAIC DESIGN SYSTEM — Reference token system from dashboard design
|
||||||
Philosophy: "Good design is as little design as possible." - Dieter Rams
|
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
CSS Custom Properties - Light Theme (Default)
|
Primitive Tokens (Dark-first — dark is the default theme)
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
/* Base colors - increased contrast from surfaces */
|
/* Mosaic design tokens — dark palette (default) */
|
||||||
--color-background: 245 247 250;
|
--ms-bg-950: #080b12;
|
||||||
--color-foreground: 15 23 42;
|
--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;
|
||||||
|
|
||||||
/* Surface hierarchy (elevation levels) - improved contrast */
|
/* Semantic aliases — dark theme is default */
|
||||||
--surface-0: 255 255 255;
|
--bg: var(--ms-bg-900);
|
||||||
--surface-1: 250 251 252;
|
--bg-deep: var(--ms-bg-950);
|
||||||
--surface-2: 241 245 249;
|
--bg-mid: var(--ms-bg-850);
|
||||||
--surface-3: 226 232 240;
|
--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);
|
||||||
|
|
||||||
/* Text hierarchy */
|
/* Typography */
|
||||||
--text-primary: 15 23 42;
|
--font: var(--font-outfit, 'Outfit'), system-ui, sans-serif;
|
||||||
--text-secondary: 51 65 85;
|
--mono: var(--font-fira-code, 'Fira Code'), 'Cascadia Code', monospace;
|
||||||
--text-tertiary: 71 85 105;
|
|
||||||
--text-muted: 100 116 139;
|
|
||||||
|
|
||||||
/* Border colors - stronger borders for light mode */
|
/* Radius scale */
|
||||||
--border-default: 203 213 225;
|
--r: 8px;
|
||||||
--border-subtle: 226 232 240;
|
--r-sm: 5px;
|
||||||
--border-strong: 148 163 184;
|
--r-lg: 12px;
|
||||||
|
--r-xl: 16px;
|
||||||
|
|
||||||
/* Brand accent - Indigo (professional, trustworthy) */
|
/* Layout dimensions */
|
||||||
--accent-primary: 79 70 229;
|
--sidebar-w: 260px;
|
||||||
--accent-primary-hover: 67 56 202;
|
--topbar-h: 56px;
|
||||||
--accent-primary-light: 238 242 255;
|
--terminal-h: 220px;
|
||||||
--accent-primary-muted: 199 210 254;
|
|
||||||
|
|
||||||
/* Semantic colors - Success (Emerald) */
|
/* Easing */
|
||||||
--semantic-success: 16 185 129;
|
--ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--semantic-success-light: 209 250 229;
|
|
||||||
--semantic-success-dark: 6 95 70;
|
|
||||||
|
|
||||||
/* Semantic colors - Warning (Amber) */
|
/* Legacy shadow tokens (retained for component compat) */
|
||||||
--semantic-warning: 245 158 11;
|
|
||||||
--semantic-warning-light: 254 243 199;
|
|
||||||
--semantic-warning-dark: 146 64 14;
|
|
||||||
|
|
||||||
/* Semantic colors - Error (Rose) */
|
|
||||||
--semantic-error: 244 63 94;
|
|
||||||
--semantic-error-light: 255 228 230;
|
|
||||||
--semantic-error-dark: 159 18 57;
|
|
||||||
|
|
||||||
/* Semantic colors - Info (Sky) */
|
|
||||||
--semantic-info: 14 165 233;
|
|
||||||
--semantic-info-light: 224 242 254;
|
|
||||||
--semantic-info-dark: 3 105 161;
|
|
||||||
|
|
||||||
/* Focus ring */
|
|
||||||
--focus-ring: 99 102 241;
|
|
||||||
--focus-ring-offset: 255 255 255;
|
|
||||||
|
|
||||||
/* Shadows - visible but subtle */
|
|
||||||
--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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
CSS Custom Properties - Dark Theme
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
.dark {
|
|
||||||
--color-background: 3 7 18;
|
|
||||||
--color-foreground: 248 250 252;
|
|
||||||
|
|
||||||
/* Surface hierarchy (elevation levels) */
|
|
||||||
--surface-0: 15 23 42;
|
|
||||||
--surface-1: 30 41 59;
|
|
||||||
--surface-2: 51 65 85;
|
|
||||||
--surface-3: 71 85 105;
|
|
||||||
|
|
||||||
/* Text hierarchy */
|
|
||||||
--text-primary: 248 250 252;
|
|
||||||
--text-secondary: 203 213 225;
|
|
||||||
--text-tertiary: 148 163 184;
|
|
||||||
--text-muted: 100 116 139;
|
|
||||||
|
|
||||||
/* Border colors */
|
|
||||||
--border-default: 51 65 85;
|
|
||||||
--border-subtle: 30 41 59;
|
|
||||||
--border-strong: 71 85 105;
|
|
||||||
|
|
||||||
/* Brand accent adjustments for dark mode */
|
|
||||||
--accent-primary: 129 140 248;
|
|
||||||
--accent-primary-hover: 165 180 252;
|
|
||||||
--accent-primary-light: 30 27 75;
|
|
||||||
--accent-primary-muted: 55 48 163;
|
|
||||||
|
|
||||||
/* Semantic colors adjustments */
|
|
||||||
--semantic-success: 52 211 153;
|
|
||||||
--semantic-success-light: 6 78 59;
|
|
||||||
--semantic-success-dark: 167 243 208;
|
|
||||||
|
|
||||||
--semantic-warning: 251 191 36;
|
|
||||||
--semantic-warning-light: 120 53 15;
|
|
||||||
--semantic-warning-dark: 253 230 138;
|
|
||||||
|
|
||||||
--semantic-error: 251 113 133;
|
|
||||||
--semantic-error-light: 136 19 55;
|
|
||||||
--semantic-error-dark: 253 164 175;
|
|
||||||
|
|
||||||
--semantic-info: 56 189 248;
|
|
||||||
--semantic-info-light: 12 74 110;
|
|
||||||
--semantic-info-dark: 186 230 253;
|
|
||||||
|
|
||||||
/* Focus ring */
|
|
||||||
--focus-ring: 129 140 248;
|
|
||||||
--focus-ring-offset: 15 23 42;
|
|
||||||
|
|
||||||
/* Shadows - subtle glow in dark mode */
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
|
--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-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);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.5), 0 4px 6px -4px rgb(0 0 0 / 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Light Theme Override — applied via data-theme attribute on <html>
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
[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;
|
||||||
|
|
||||||
|
/* Re-alias semantics for light — identical structure, primitive tokens differ */
|
||||||
|
--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);
|
||||||
|
|
||||||
|
/* Lighter shadows for light mode */
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Base Styles
|
Base Styles
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
* {
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
font-size: 15px;
|
||||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: rgb(var(--text-primary));
|
font-family: var(--font);
|
||||||
background: rgb(var(--color-background));
|
background: var(--bg);
|
||||||
font-size: 14px;
|
color: var(--text);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
transition: background-color 0.15s ease, color 0.15s ease;
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subtle grain/noise overlay for texture */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Focus States - Accessible & Visible
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@layer base {
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--ms-blue-400);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus:not(:focus-visible) {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Scrollbar Styling - Minimal & Professional
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
App Shell Grid Layout
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
@@ -182,102 +285,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-mono {
|
.text-mono {
|
||||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
font-family: var(--mono);
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Text color utilities */
|
|
||||||
.text-primary {
|
|
||||||
color: rgb(var(--text-primary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
color: rgb(var(--text-secondary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-tertiary {
|
|
||||||
color: rgb(var(--text-tertiary));
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: rgb(var(--text-muted));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Surface & Card Utilities
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
@layer utilities {
|
|
||||||
.surface-0 {
|
|
||||||
background-color: rgb(var(--surface-0));
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-1 {
|
|
||||||
background-color: rgb(var(--surface-1));
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-2 {
|
|
||||||
background-color: rgb(var(--surface-2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.surface-3 {
|
|
||||||
background-color: rgb(var(--surface-3));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-default {
|
|
||||||
border-color: rgb(var(--border-default));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-subtle {
|
|
||||||
border-color: rgb(var(--border-subtle));
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-strong {
|
|
||||||
border-color: rgb(var(--border-strong));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Focus States - Accessible & Visible
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
@layer base {
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid rgb(var(--focus-ring));
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove default focus for mouse users */
|
|
||||||
:focus:not(:focus-visible) {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
|
||||||
Scrollbar Styling - Minimal & Professional
|
|
||||||
----------------------------------------------------------------------------- */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: rgb(var(--text-muted) / 0.4);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: rgb(var(--text-muted) / 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Firefox */
|
|
||||||
* {
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: rgb(var(--text-muted) / 0.4) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
@@ -292,40 +303,46 @@ body {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: rgb(var(--accent-primary));
|
background: linear-gradient(135deg, var(--ms-blue-500), var(--ms-purple-500));
|
||||||
color: white;
|
color: white;
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background-color: rgb(var(--accent-primary-hover));
|
box-shadow: 0 8px 28px rgba(47, 128, 255, 0.38);
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface);
|
||||||
color: rgb(var(--text-primary));
|
color: var(--text-2);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
.btn-secondary:hover:not(:disabled) {
|
||||||
background-color: rgb(var(--surface-3));
|
background-color: var(--surface-2);
|
||||||
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost {
|
.btn-ghost {
|
||||||
@apply btn px-3 py-2;
|
@apply btn px-3 py-2;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
color: rgb(var(--text-secondary));
|
color: var(--muted);
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface);
|
||||||
color: rgb(var(--text-primary));
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
@apply btn px-4 py-2;
|
@apply btn px-4 py-2;
|
||||||
background-color: rgb(var(--semantic-error));
|
background-color: var(--danger);
|
||||||
color: white;
|
color: white;
|
||||||
|
border-radius: var(--r);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
.btn-danger:hover:not(:disabled) {
|
||||||
@@ -346,34 +363,36 @@ body {
|
|||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
@layer components {
|
@layer components {
|
||||||
.input {
|
.input {
|
||||||
@apply w-full rounded-md px-3 py-2 text-sm transition-all duration-150;
|
@apply w-full text-sm transition-all duration-150;
|
||||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-0;
|
@apply focus:outline-none;
|
||||||
background-color: rgb(var(--surface-0));
|
background-color: var(--bg);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
color: rgb(var(--text-primary));
|
border-radius: var(--r);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 11px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input::placeholder {
|
.input::placeholder {
|
||||||
color: rgb(var(--text-muted));
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border-color: rgb(var(--accent-primary));
|
border-color: var(--primary);
|
||||||
box-shadow: 0 0 0 3px rgb(var(--accent-primary) / 0.1);
|
box-shadow: 0 0 0 3px rgba(47, 128, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:disabled {
|
.input:disabled {
|
||||||
@apply opacity-50 cursor-not-allowed;
|
@apply opacity-50 cursor-not-allowed;
|
||||||
background-color: rgb(var(--surface-1));
|
background-color: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-error {
|
.input-error {
|
||||||
border-color: rgb(var(--semantic-error));
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-error:focus {
|
.input-error:focus {
|
||||||
border-color: rgb(var(--semantic-error));
|
border-color: var(--danger);
|
||||||
box-shadow: 0 0 0 3px rgb(var(--semantic-error) / 0.1);
|
box-shadow: 0 0 0 3px rgba(229, 72, 77, 0.12);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,8 +402,8 @@ body {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.card {
|
.card {
|
||||||
@apply rounded-lg p-4;
|
@apply rounded-lg p-4;
|
||||||
background-color: rgb(var(--surface-0));
|
background-color: var(--surface);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +417,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-interactive:hover {
|
.card-interactive:hover {
|
||||||
border-color: rgb(var(--border-strong));
|
border-color: var(--muted);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,33 +431,33 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.badge-success {
|
.badge-success {
|
||||||
background-color: rgb(var(--semantic-success-light));
|
background-color: rgba(20, 184, 166, 0.15);
|
||||||
color: rgb(var(--semantic-success-dark));
|
color: var(--ms-teal-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-warning {
|
.badge-warning {
|
||||||
background-color: rgb(var(--semantic-warning-light));
|
background-color: rgba(245, 158, 11, 0.15);
|
||||||
color: rgb(var(--semantic-warning-dark));
|
color: var(--ms-amber-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-error {
|
.badge-error {
|
||||||
background-color: rgb(var(--semantic-error-light));
|
background-color: rgba(229, 72, 77, 0.15);
|
||||||
color: rgb(var(--semantic-error-dark));
|
color: var(--ms-red-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-info {
|
.badge-info {
|
||||||
background-color: rgb(var(--semantic-info-light));
|
background-color: rgba(47, 128, 255, 0.15);
|
||||||
color: rgb(var(--semantic-info-dark));
|
color: var(--ms-blue-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-neutral {
|
.badge-neutral {
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface-2);
|
||||||
color: rgb(var(--text-secondary));
|
color: var(--text-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-primary {
|
.badge-primary {
|
||||||
background-color: rgb(var(--accent-primary-light));
|
background-color: rgba(47, 128, 255, 0.15);
|
||||||
color: rgb(var(--accent-primary));
|
color: var(--primary-l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,26 +470,29 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-success {
|
.status-dot-success {
|
||||||
background-color: rgb(var(--semantic-success));
|
background-color: var(--success);
|
||||||
|
box-shadow: 0 0 5px var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-warning {
|
.status-dot-warning {
|
||||||
background-color: rgb(var(--semantic-warning));
|
background-color: var(--warn);
|
||||||
|
box-shadow: 0 0 5px var(--warn);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-error {
|
.status-dot-error {
|
||||||
background-color: rgb(var(--semantic-error));
|
background-color: var(--danger);
|
||||||
|
box-shadow: 0 0 5px var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-info {
|
.status-dot-info {
|
||||||
background-color: rgb(var(--semantic-info));
|
background-color: var(--primary);
|
||||||
|
box-shadow: 0 0 5px var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot-neutral {
|
.status-dot-neutral {
|
||||||
background-color: rgb(var(--text-muted));
|
background-color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pulsing indicator for live/active status */
|
|
||||||
.status-dot-pulse {
|
.status-dot-pulse {
|
||||||
@apply relative;
|
@apply relative;
|
||||||
}
|
}
|
||||||
@@ -489,12 +511,12 @@ body {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.kbd {
|
.kbd {
|
||||||
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
||||||
background-color: rgb(var(--surface-2));
|
background-color: var(--surface-2);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
color: rgb(var(--text-tertiary));
|
color: var(--muted);
|
||||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
font-family: var(--mono);
|
||||||
min-width: 1.5rem;
|
min-width: 1.5rem;
|
||||||
box-shadow: 0 1px 0 rgb(var(--border-strong));
|
box-shadow: 0 1px 0 var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kbd-group {
|
.kbd-group {
|
||||||
@@ -512,13 +534,13 @@ body {
|
|||||||
|
|
||||||
.table-pro thead {
|
.table-pro thead {
|
||||||
@apply sticky top-0;
|
@apply sticky top-0;
|
||||||
background-color: rgb(var(--surface-1));
|
background-color: var(--surface);
|
||||||
border-bottom: 1px solid rgb(var(--border-default));
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th {
|
.table-pro th {
|
||||||
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
||||||
color: rgb(var(--text-tertiary));
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th.sortable {
|
.table-pro th.sortable {
|
||||||
@@ -526,16 +548,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-pro th.sortable:hover {
|
.table-pro th.sortable:hover {
|
||||||
color: rgb(var(--text-primary));
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro tbody tr {
|
.table-pro tbody tr {
|
||||||
border-bottom: 1px solid rgb(var(--border-subtle));
|
border-bottom: 1px solid var(--border);
|
||||||
transition: background-color 0.1s ease;
|
transition: background-color 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro tbody tr:hover {
|
.table-pro tbody tr:hover {
|
||||||
background-color: rgb(var(--surface-1));
|
background-color: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-pro td {
|
.table-pro td {
|
||||||
@@ -555,9 +577,9 @@ body {
|
|||||||
@apply animate-pulse rounded;
|
@apply animate-pulse rounded;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
90deg,
|
90deg,
|
||||||
rgb(var(--surface-2)) 0%,
|
var(--surface) 0%,
|
||||||
rgb(var(--surface-1)) 50%,
|
var(--surface-2) 50%,
|
||||||
rgb(var(--surface-2)) 100%
|
var(--surface) 100%
|
||||||
);
|
);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
@@ -590,15 +612,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg;
|
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto;
|
||||||
background-color: rgb(var(--surface-0));
|
background-color: var(--surface);
|
||||||
border: 1px solid rgb(var(--border-default));
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@apply flex items-center justify-between p-4 border-b;
|
@apply flex items-center justify-between p-4 border-b;
|
||||||
border-color: rgb(var(--border-default));
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body {
|
.modal-body {
|
||||||
@@ -607,7 +630,7 @@ body {
|
|||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
@apply flex items-center justify-end gap-3 p-4 border-t;
|
@apply flex items-center justify-end gap-3 p-4 border-t;
|
||||||
border-color: rgb(var(--border-default));
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,9 +640,10 @@ body {
|
|||||||
@layer components {
|
@layer components {
|
||||||
.tooltip {
|
.tooltip {
|
||||||
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
||||||
background-color: rgb(var(--text-primary));
|
background-color: var(--text);
|
||||||
color: rgb(var(--color-background));
|
color: var(--bg);
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip::before {
|
.tooltip::before {
|
||||||
@@ -630,7 +654,7 @@ body {
|
|||||||
|
|
||||||
.tooltip-top::before {
|
.tooltip-top::before {
|
||||||
@apply left-1/2 top-full -translate-x-1/2;
|
@apply left-1/2 top-full -translate-x-1/2;
|
||||||
border-top-color: rgb(var(--text-primary));
|
border-top-color: var(--text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,12 +704,10 @@ body {
|
|||||||
animation: scaleIn 0.15s ease-out;
|
animation: scaleIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Message animation - subtle for chat */
|
|
||||||
.message-animate {
|
.message-animate {
|
||||||
animation: slideIn 0.2s ease-out;
|
animation: slideIn 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Menu dropdown animation */
|
|
||||||
.animate-menu-enter {
|
.animate-menu-enter {
|
||||||
animation: scaleIn 0.1s ease-out;
|
animation: scaleIn 0.1s ease-out;
|
||||||
}
|
}
|
||||||
@@ -710,13 +732,8 @@ body {
|
|||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
@media (prefers-contrast: high) {
|
@media (prefers-contrast: high) {
|
||||||
:root {
|
:root {
|
||||||
--border-default: 100 116 139;
|
--border: #4a5a78;
|
||||||
--border-strong: 71 85 105;
|
--muted: #a0b0cc;
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--border-default: 148 163 184;
|
|
||||||
--border-strong: 203 213 225;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Outfit, Fira_Code } from "next/font/google";
|
||||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||||
import { ErrorBoundary } from "@/components/error-boundary";
|
import { ErrorBoundary } from "@/components/error-boundary";
|
||||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||||
@@ -12,6 +13,18 @@ export const metadata: Metadata = {
|
|||||||
description: "Mosaic Stack Web Application",
|
description: "Mosaic Stack Web Application",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const outfit = Outfit({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-outfit",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firaCode = Fira_Code({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-fira-code",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime env vars injected as a synchronous script so client-side modules
|
* Runtime env vars injected as a synchronous script so client-side modules
|
||||||
* can read them before React hydration. This allows Docker env vars to
|
* can read them before React hydration. This allows Docker env vars to
|
||||||
@@ -34,7 +47,7 @@ function runtimeEnvScript(): string {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
export default function RootLayout({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en" className={`${outfit.variable} ${firaCode.variable}`}>
|
||||||
<head>
|
<head>
|
||||||
<script dangerouslySetInnerHTML={{ __html: runtimeEnvScript() }} />
|
<script dangerouslySetInnerHTML={{ __html: runtimeEnvScript() }} />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
114
apps/web/src/components/layout/AppHeader.tsx
Normal file
114
apps/web/src/components/layout/AppHeader.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
|
import { LogoutButton } from "@/components/auth/LogoutButton";
|
||||||
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function AppHeader(): React.JSX.Element {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="app-header">
|
||||||
|
{/* Brand / Logo — full-width header, left-anchored */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Spacer */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Right side controls */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<span className="hidden sm:block text-xs px-2" style={{ color: "var(--muted)" }}>
|
||||||
|
{user.name || user.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LogoutButton variant="secondary" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
160
apps/web/src/components/layout/AppSidebar.tsx
Normal file
160
apps/web/src/components/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
href: "/",
|
||||||
|
label: "Dashboard",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/tasks",
|
||||||
|
label: "Tasks",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9 11l3 3L22 4" />
|
||||||
|
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/calendar",
|
||||||
|
label: "Calendar",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect x="3" y="4" width="18" height="18" rx="2" />
|
||||||
|
<path d="M16 2v4M8 2v4M3 10h18" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/knowledge",
|
||||||
|
label: "Knowledge",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 016.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/usage",
|
||||||
|
label: "Usage",
|
||||||
|
icon: (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M18 20V10M12 20V4M6 20v-6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application sidebar — navigation only, no brand/logo.
|
||||||
|
* Logo lives in AppHeader per MS15 design spec.
|
||||||
|
*/
|
||||||
|
export function AppSidebar(): React.JSX.Element {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="app-sidebar">
|
||||||
|
{/* Navigation body */}
|
||||||
|
<nav className="flex-1 overflow-y-auto p-2" aria-label="Main navigation">
|
||||||
|
<div className="mb-4 mt-2">
|
||||||
|
<p
|
||||||
|
className="px-2 mb-1 text-xs font-semibold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted)" }}
|
||||||
|
>
|
||||||
|
Navigation
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center gap-3 px-2 py-2 rounded text-sm font-medium transition-colors relative"
|
||||||
|
style={
|
||||||
|
isActive
|
||||||
|
? { color: "var(--text)", background: "var(--surface)" }
|
||||||
|
: { color: "var(--muted)" }
|
||||||
|
}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<span
|
||||||
|
className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-r"
|
||||||
|
style={{ background: "var(--primary)" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="flex-shrink-0" style={{ opacity: isActive ? 1 : 0.7 }}>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
|
|||||||
// Sun icon for dark mode (click to switch to light)
|
// Sun icon for dark mode (click to switch to light)
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
style={{ color: "rgb(var(--semantic-warning))" }}
|
style={{ color: "var(--warn)" }}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -33,7 +33,7 @@ export function ThemeToggle({ className = "" }: ThemeToggleProps): React.JSX.Ele
|
|||||||
// Moon icon for light mode (click to switch to dark)
|
// Moon icon for light mode (click to switch to dark)
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5"
|
className="h-5 w-5"
|
||||||
style={{ color: "rgb(var(--text-secondary))" }}
|
style={{ color: "var(--text-2)" }}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
|||||||
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal file
100
apps/web/src/components/ui/MosaicLogo.tsx
Normal 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;
|
||||||
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal file
49
apps/web/src/components/ui/MosaicSpinner.tsx
Normal 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;
|
||||||
@@ -29,6 +29,21 @@ function getStoredTheme(): Theme {
|
|||||||
return "system";
|
return "system";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the resolved theme to the <html> element via data-theme attribute.
|
||||||
|
* The default (no attribute or data-theme="dark") renders dark — dark is default.
|
||||||
|
* Light theme requires data-theme="light".
|
||||||
|
*/
|
||||||
|
function applyThemeAttribute(resolved: "light" | "dark"): void {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (resolved === "light") {
|
||||||
|
root.setAttribute("data-theme", "light");
|
||||||
|
} else {
|
||||||
|
// Remove the attribute so the default (dark) CSS variables apply.
|
||||||
|
root.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
interface ThemeProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
defaultTheme?: Theme;
|
defaultTheme?: Theme;
|
||||||
@@ -46,19 +61,18 @@ export function ThemeProvider({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
const storedTheme = getStoredTheme();
|
const storedTheme = getStoredTheme();
|
||||||
|
const resolved = storedTheme === "system" ? getSystemTheme() : storedTheme;
|
||||||
setThemeState(storedTheme);
|
setThemeState(storedTheme);
|
||||||
setResolvedTheme(storedTheme === "system" ? getSystemTheme() : storedTheme);
|
setResolvedTheme(resolved);
|
||||||
|
applyThemeAttribute(resolved);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Apply theme class to html element
|
// Apply theme via data-theme attribute on html element
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
|
||||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||||
|
applyThemeAttribute(resolved);
|
||||||
root.classList.remove("light", "dark");
|
|
||||||
root.classList.add(resolved);
|
|
||||||
setResolvedTheme(resolved);
|
setResolvedTheme(resolved);
|
||||||
}, [theme, mounted]);
|
}, [theme, mounted]);
|
||||||
|
|
||||||
@@ -68,9 +82,9 @@ export function ThemeProvider({
|
|||||||
|
|
||||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const handleChange = (e: MediaQueryListEvent): void => {
|
const handleChange = (e: MediaQueryListEvent): void => {
|
||||||
setResolvedTheme(e.matches ? "dark" : "light");
|
const resolved = e.matches ? "dark" : "light";
|
||||||
document.documentElement.classList.remove("light", "dark");
|
setResolvedTheme(resolved);
|
||||||
document.documentElement.classList.add(e.matches ? "dark" : "light");
|
applyThemeAttribute(resolved);
|
||||||
};
|
};
|
||||||
|
|
||||||
mediaQuery.addEventListener("change", handleChange);
|
mediaQuery.addEventListener("change", handleChange);
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
import type { Config } from "tailwindcss";
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: "class",
|
// Use data-theme attribute selector for dark mode instead of .dark class
|
||||||
|
darkMode: ["selector", '[data-theme="dark"]'],
|
||||||
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/ui/src/**/*.{js,ts,jsx,tsx,mdx}"],
|
content: ["./src/**/*.{js,ts,jsx,tsx,mdx}", "../../packages/ui/src/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-outfit)", "system-ui", "sans-serif"],
|
||||||
|
mono: ["var(--font-fira-code)", "Cascadia Code", "monospace"],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
// Expose Mosaic semantic tokens as Tailwind colors
|
||||||
|
bg: "var(--bg)",
|
||||||
|
"bg-deep": "var(--bg-deep)",
|
||||||
|
"bg-mid": "var(--bg-mid)",
|
||||||
|
surface: "var(--surface)",
|
||||||
|
"surface-2": "var(--surface-2)",
|
||||||
|
border: "var(--border)",
|
||||||
|
text: "var(--text)",
|
||||||
|
"text-2": "var(--text-2)",
|
||||||
|
muted: "var(--muted)",
|
||||||
|
primary: "var(--primary)",
|
||||||
|
"primary-l": "var(--primary-l)",
|
||||||
|
danger: "var(--danger)",
|
||||||
|
success: "var(--success)",
|
||||||
|
warn: "var(--warn)",
|
||||||
|
purple: "var(--purple)",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
};
|
};
|
||||||
|
|||||||
45
docs/scratchpads/ms15-fe-006-mosaic-logo-spinner.md
Normal file
45
docs/scratchpads/ms15-fe-006-mosaic-logo-spinner.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# MS15-FE-006: MosaicLogo and MosaicSpinner Components
|
||||||
|
|
||||||
|
## Task
|
||||||
|
|
||||||
|
Create Mosaic logo icon component and spinner wrapper for use as the site-wide loading indicator.
|
||||||
|
|
||||||
|
## Files to Create
|
||||||
|
|
||||||
|
1. `apps/web/src/components/ui/MosaicLogo.tsx` — 5-element logo icon
|
||||||
|
2. `apps/web/src/components/ui/MosaicSpinner.tsx` — spinner wrapper
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
1. `apps/web/src/app/(authenticated)/layout.tsx` — replace loading spinner (isLoading block only)
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
- 4 corner squares: blue (TL), purple (TR), teal (BR), amber (BL)
|
||||||
|
- 1 center circle: pink
|
||||||
|
- CSS vars: --ms-blue-500, --ms-purple-500, --ms-teal-500, --ms-amber-500, --ms-pink-500
|
||||||
|
- Animation: linear 360deg rotation
|
||||||
|
|
||||||
|
## Props
|
||||||
|
|
||||||
|
### MosaicLogo
|
||||||
|
|
||||||
|
- size?: number (default 36)
|
||||||
|
- spinning?: boolean (default false)
|
||||||
|
- spinDuration?: number (default 20) seconds
|
||||||
|
- className?: string
|
||||||
|
|
||||||
|
### MosaicSpinner
|
||||||
|
|
||||||
|
- Wraps MosaicLogo with spinning=true
|
||||||
|
- label?: string — optional text label below
|
||||||
|
- fullPage?: boolean — center on screen
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [x] Scratchpad created
|
||||||
|
- [ ] MosaicLogo.tsx created
|
||||||
|
- [ ] MosaicSpinner.tsx created
|
||||||
|
- [ ] layout.tsx updated
|
||||||
|
- [ ] Lint clean
|
||||||
|
- [ ] Committed and pushed
|
||||||
Reference in New Issue
Block a user