feat: add theme system from jarvis frontend
This commit is contained in:
@@ -15,11 +15,17 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^9.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@mosaic/shared": "workspace:*",
|
||||
"@mosaic/ui": "workspace:*",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@xyflow/react": "^12.5.3",
|
||||
"date-fns": "^4.1.0",
|
||||
"elkjs": "^0.9.3",
|
||||
"lucide-react": "^0.563.0",
|
||||
"mermaid": "^11.4.1",
|
||||
"next": "^16.1.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
@@ -2,19 +2,747 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* =============================================================================
|
||||
DESIGN C: PROFESSIONAL/ENTERPRISE DESIGN SYSTEM
|
||||
Philosophy: "Good design is as little design as possible." - Dieter Rams
|
||||
============================================================================= */
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
CSS Custom Properties - Light Theme (Default)
|
||||
----------------------------------------------------------------------------- */
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-rgb: 255, 255, 255;
|
||||
/* Base colors - increased contrast from surfaces */
|
||||
--color-background: 245 247 250;
|
||||
--color-foreground: 15 23 42;
|
||||
|
||||
/* Surface hierarchy (elevation levels) - improved contrast */
|
||||
--surface-0: 255 255 255;
|
||||
--surface-1: 250 251 252;
|
||||
--surface-2: 241 245 249;
|
||||
--surface-3: 226 232 240;
|
||||
|
||||
/* Text hierarchy */
|
||||
--text-primary: 15 23 42;
|
||||
--text-secondary: 51 65 85;
|
||||
--text-tertiary: 71 85 105;
|
||||
--text-muted: 100 116 139;
|
||||
|
||||
/* Border colors - stronger borders for light mode */
|
||||
--border-default: 203 213 225;
|
||||
--border-subtle: 226 232 240;
|
||||
--border-strong: 148 163 184;
|
||||
|
||||
/* Brand accent - Indigo (professional, trustworthy) */
|
||||
--accent-primary: 79 70 229;
|
||||
--accent-primary-hover: 67 56 202;
|
||||
--accent-primary-light: 238 242 255;
|
||||
--accent-primary-muted: 199 210 254;
|
||||
|
||||
/* Semantic colors - Success (Emerald) */
|
||||
--semantic-success: 16 185 129;
|
||||
--semantic-success-light: 209 250 229;
|
||||
--semantic-success-dark: 6 95 70;
|
||||
|
||||
/* Semantic colors - Warning (Amber) */
|
||||
--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);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-rgb: 0, 0, 0;
|
||||
}
|
||||
/* -----------------------------------------------------------------------------
|
||||
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-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
|
||||
----------------------------------------------------------------------------- */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
color: rgb(var(--foreground-rgb));
|
||||
background: rgb(var(--background-rgb));
|
||||
color: rgb(var(--text-primary));
|
||||
background: rgb(var(--color-background));
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Typography Utilities
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer utilities {
|
||||
.text-display {
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.text-heading-1 {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.text-heading-2 {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.text-body {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.text-caption {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.text-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
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;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Button Component Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-all duration-150;
|
||||
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
|
||||
@apply disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn px-4 py-2;
|
||||
background-color: rgb(var(--accent-primary));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: rgb(var(--accent-primary-hover));
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn px-4 py-2;
|
||||
background-color: rgb(var(--surface-2));
|
||||
color: rgb(var(--text-primary));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: rgb(var(--surface-3));
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply btn px-3 py-2;
|
||||
background-color: transparent;
|
||||
color: rgb(var(--text-secondary));
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: rgb(var(--surface-2));
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn px-4 py-2;
|
||||
background-color: rgb(var(--semantic-error));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@apply px-3 py-1.5 text-xs;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@apply px-6 py-3 text-base;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Input Component Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.input {
|
||||
@apply w-full rounded-md px-3 py-2 text-sm transition-all duration-150;
|
||||
@apply focus:outline-none focus:ring-2 focus:ring-offset-0;
|
||||
background-color: rgb(var(--surface-0));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: rgb(var(--accent-primary));
|
||||
box-shadow: 0 0 0 3px rgb(var(--accent-primary) / 0.1);
|
||||
}
|
||||
|
||||
.input:disabled {
|
||||
@apply opacity-50 cursor-not-allowed;
|
||||
background-color: rgb(var(--surface-1));
|
||||
}
|
||||
|
||||
.input-error {
|
||||
border-color: rgb(var(--semantic-error));
|
||||
}
|
||||
|
||||
.input-error:focus {
|
||||
border-color: rgb(var(--semantic-error));
|
||||
box-shadow: 0 0 0 3px rgb(var(--semantic-error) / 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Card Component Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-lg p-4;
|
||||
background-color: rgb(var(--surface-0));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-elevated {
|
||||
@apply card;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
@apply card transition-all duration-150;
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
border-color: rgb(var(--border-strong));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Badge Component Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.badge {
|
||||
@apply inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: rgb(var(--semantic-success-light));
|
||||
color: rgb(var(--semantic-success-dark));
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: rgb(var(--semantic-warning-light));
|
||||
color: rgb(var(--semantic-warning-dark));
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: rgb(var(--semantic-error-light));
|
||||
color: rgb(var(--semantic-error-dark));
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: rgb(var(--semantic-info-light));
|
||||
color: rgb(var(--semantic-info-dark));
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background-color: rgb(var(--surface-2));
|
||||
color: rgb(var(--text-secondary));
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: rgb(var(--accent-primary-light));
|
||||
color: rgb(var(--accent-primary));
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Status Indicator Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.status-dot {
|
||||
@apply inline-block h-2 w-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-dot-success {
|
||||
background-color: rgb(var(--semantic-success));
|
||||
}
|
||||
|
||||
.status-dot-warning {
|
||||
background-color: rgb(var(--semantic-warning));
|
||||
}
|
||||
|
||||
.status-dot-error {
|
||||
background-color: rgb(var(--semantic-error));
|
||||
}
|
||||
|
||||
.status-dot-info {
|
||||
background-color: rgb(var(--semantic-info));
|
||||
}
|
||||
|
||||
.status-dot-neutral {
|
||||
background-color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
/* Pulsing indicator for live/active status */
|
||||
.status-dot-pulse {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
.status-dot-pulse::before {
|
||||
content: "";
|
||||
@apply absolute inset-0 rounded-full animate-ping;
|
||||
background-color: inherit;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Keyboard Shortcut Styling
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.kbd {
|
||||
@apply inline-flex items-center justify-center rounded px-1.5 py-0.5 text-xs font-medium;
|
||||
background-color: rgb(var(--surface-2));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
color: rgb(var(--text-tertiary));
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
|
||||
min-width: 1.5rem;
|
||||
box-shadow: 0 1px 0 rgb(var(--border-strong));
|
||||
}
|
||||
|
||||
.kbd-group {
|
||||
@apply inline-flex items-center gap-1;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Table Styles - Dense & Professional
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.table-pro {
|
||||
@apply w-full text-sm;
|
||||
}
|
||||
|
||||
.table-pro thead {
|
||||
@apply sticky top-0;
|
||||
background-color: rgb(var(--surface-1));
|
||||
border-bottom: 1px solid rgb(var(--border-default));
|
||||
}
|
||||
|
||||
.table-pro th {
|
||||
@apply px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider;
|
||||
color: rgb(var(--text-tertiary));
|
||||
}
|
||||
|
||||
.table-pro th.sortable {
|
||||
@apply cursor-pointer select-none;
|
||||
}
|
||||
|
||||
.table-pro th.sortable:hover {
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
.table-pro tbody tr {
|
||||
border-bottom: 1px solid rgb(var(--border-subtle));
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
.table-pro tbody tr:hover {
|
||||
background-color: rgb(var(--surface-1));
|
||||
}
|
||||
|
||||
.table-pro td {
|
||||
@apply px-4 py-3;
|
||||
}
|
||||
|
||||
.table-pro-dense td {
|
||||
@apply py-2;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Skeleton Loading Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.skeleton {
|
||||
@apply animate-pulse rounded;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgb(var(--surface-2)) 0%,
|
||||
rgb(var(--surface-1)) 50%,
|
||||
rgb(var(--surface-2)) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.skeleton-text {
|
||||
@apply skeleton h-4 w-full;
|
||||
}
|
||||
|
||||
.skeleton-text-sm {
|
||||
@apply skeleton h-3 w-3/4;
|
||||
}
|
||||
|
||||
.skeleton-avatar {
|
||||
@apply skeleton h-10 w-10 rounded-full;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
@apply skeleton h-32 w-full;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Modal & Dialog Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.modal-backdrop {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center p-4;
|
||||
background-color: rgb(0 0 0 / 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@apply relative max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg;
|
||||
background-color: rgb(var(--surface-0));
|
||||
border: 1px solid rgb(var(--border-default));
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@apply flex items-center justify-between p-4 border-b;
|
||||
border-color: rgb(var(--border-default));
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
@apply flex items-center justify-end gap-3 p-4 border-t;
|
||||
border-color: rgb(var(--border-default));
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Tooltip Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@layer components {
|
||||
.tooltip {
|
||||
@apply absolute z-50 rounded px-2 py-1 text-xs font-medium;
|
||||
background-color: rgb(var(--text-primary));
|
||||
color: rgb(var(--color-background));
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.tooltip::before {
|
||||
content: "";
|
||||
@apply absolute;
|
||||
border: 4px solid transparent;
|
||||
}
|
||||
|
||||
.tooltip-top::before {
|
||||
@apply left-1/2 top-full -translate-x-1/2;
|
||||
border-top-color: rgb(var(--text-primary));
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Animations - Functional Only
|
||||
----------------------------------------------------------------------------- */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Message animation - subtle for chat */
|
||||
.message-animate {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Menu dropdown animation */
|
||||
.animate-menu-enter {
|
||||
animation: scaleIn 0.1s ease-out;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Responsive Typography Adjustments
|
||||
----------------------------------------------------------------------------- */
|
||||
@media (max-width: 640px) {
|
||||
.text-display {
|
||||
font-size: 1.5rem;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.text-heading-1 {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
High Contrast Mode Support
|
||||
----------------------------------------------------------------------------- */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-default: 100 116 139;
|
||||
--border-strong: 71 85 105;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--border-default: 148 163 184;
|
||||
--border-strong: 203 213 225;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Reduced Motion Support
|
||||
----------------------------------------------------------------------------- */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Print Styles
|
||||
----------------------------------------------------------------------------- */
|
||||
@media print {
|
||||
body {
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { AuthProvider } from "@/lib/auth/auth-context";
|
||||
import { ErrorBoundary } from "@/components/error-boundary";
|
||||
import { ThemeProvider } from "@/providers/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -13,9 +14,11 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ErrorBoundary>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
47
apps/web/src/components/layout/ThemeToggle.tsx
Normal file
47
apps/web/src/components/layout/ThemeToggle.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "@/providers/ThemeProvider";
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className = "" }: ThemeToggleProps) {
|
||||
const { resolvedTheme, toggleTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`btn-ghost rounded-md p-2 ${className}`}
|
||||
title={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
|
||||
aria-label={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} mode`}
|
||||
>
|
||||
{resolvedTheme === "dark" ? (
|
||||
// Sun icon for dark mode (click to switch to light)
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgb(var(--semantic-warning))" }}
|
||||
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>
|
||||
) : (
|
||||
// Moon icon for light mode (click to switch to dark)
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
style={{ color: "rgb(var(--text-secondary))" }}
|
||||
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>
|
||||
);
|
||||
}
|
||||
131
apps/web/src/providers/ThemeProvider.tsx
Normal file
131
apps/web/src/providers/ThemeProvider.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolvedTheme: "light" | "dark";
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
const STORAGE_KEY = "jarvis-theme";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function getStoredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "system";
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setThemeState] = useState<Theme>(defaultTheme);
|
||||
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">("dark");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Initialize theme from storage on mount
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const storedTheme = getStoredTheme();
|
||||
setThemeState(storedTheme);
|
||||
setResolvedTheme(
|
||||
storedTheme === "system" ? getSystemTheme() : storedTheme
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Apply theme class to html element
|
||||
useEffect(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const resolved = theme === "system" ? getSystemTheme() : theme;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolved);
|
||||
setResolvedTheme(resolved);
|
||||
}, [theme, mounted]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
if (!mounted || theme !== "system") return;
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
setResolvedTheme(e.matches ? "dark" : "light");
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(e.matches ? "dark" : "light");
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [theme, mounted]);
|
||||
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem(STORAGE_KEY, newTheme);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
// Prevent flash by not rendering until mounted
|
||||
if (!mounted) {
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{
|
||||
theme: defaultTheme,
|
||||
resolvedTheme: "dark",
|
||||
setTheme: () => {},
|
||||
toggleTheme: () => {},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider
|
||||
value={{ theme, resolvedTheme, setTheme, toggleTheme }}
|
||||
>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user