From 90e9f1a4b83d213dda1a58af7a5bee72b8755a67 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 20:18:56 -0700 Subject: [PATCH 01/21] =?UTF-8?q?DPS-67:=20Design=20system=20foundation=20?= =?UTF-8?q?=E2=80=94=20tokens,=20typography=20&=20shared=20styles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure CSS custom properties into a comprehensive token system (spacing, typography, radius, z-index, transitions, surfaces, text, accent, status, shadows) with full light/dark theme coverage. Add Inter font from Google Fonts. Create shared.module.css with composable utility classes. Add global interactive element defaults and prefers-reduced-motion support. Legacy variable aliases preserve backward compatibility with existing components. --- client/index.html | 3 + client/src/index.css | 232 +++++++++++++++++++--------- client/src/styles/shared.module.css | 137 ++++++++++++++++ 3 files changed, 295 insertions(+), 77 deletions(-) create mode 100644 client/src/styles/shared.module.css diff --git a/client/index.html b/client/index.html index 10496ed..8c46668 100644 --- a/client/index.html +++ b/client/index.html @@ -4,6 +4,9 @@ + + + Depsera diff --git a/client/src/index.css b/client/src/index.css index c250cea..16078c8 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -1,106 +1,150 @@ -/* Theme Variables */ +/* ============================================================= + Design System Tokens + Swiss Modernism 2.0 — minimal, flat, crisp, data-forward + ============================================================= */ + +/* --- Theme-independent tokens --- */ :root { - /* Primary colors */ + /* Spacing (8px base grid) */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --space-8: 64px; + + /* Typography */ + --font-xs: 0.75rem; /* 12px */ + --font-sm: 0.8125rem; /* 13px */ + --font-base: 0.875rem; /* 14px */ + --font-lg: 1rem; /* 16px */ + --font-xl: 1.125rem; /* 18px */ + --font-2xl: 1.5rem; /* 24px */ + --font-normal: 400; + --font-medium: 500; + --font-semibold: 600; + --line-height-tight: 1.25; + --line-height-normal: 1.5; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + + /* Z-index scale */ + --z-dropdown: 10; + --z-sticky: 20; + --z-modal: 30; + --z-tooltip: 50; + + /* Transition durations */ + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + + /* Accent + status (same for both themes) */ + --color-accent: #3b82f6; + --color-accent-hover: #2563eb; + --color-healthy: #22c55e; + --color-warning: #f59e0b; + --color-critical: #dc2626; + --color-unknown: #6b7280; +} + +/* --- Light theme (default) --- */ +:root { + /* Surfaces */ + --color-bg: #f8fafc; + --color-surface: #ffffff; + --color-surface-hover: #f1f5f9; + --color-border: #e2e8f0; + --color-border-subtle: #f1f5f9; + + /* Text */ + --color-text: #0f172a; + --color-text-secondary: #475569; + --color-text-muted: #64748b; + + /* Shadows (minimal, for elevated elements only) */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + + /* --- Legacy aliases (existing components reference these) --- */ --color-primary: #1a1a2e; --color-primary-hover: #2d2d4a; - - /* Background colors */ - --color-bg-page: #f5f5f5; - --color-bg-card: #ffffff; + --color-bg-page: var(--color-bg); + --color-bg-card: var(--color-surface); --color-bg-sidebar: #f8f9fa; - --color-bg-input: #ffffff; - --color-bg-hover: #f9fafb; + --color-bg-input: var(--color-surface); + --color-bg-hover: var(--color-surface-hover); --color-bg-active: #e9ecef; - - /* Text colors */ - --color-text-primary: #333333; - --color-text-secondary: #666666; - --color-text-muted: #6b7280; - --color-text-heading: #1a1a2e; + --color-text-primary: var(--color-text); + --color-text-heading: var(--color-text); --color-text-inverse: #ffffff; - - /* Border colors */ - --color-border: #e5e7eb; - --color-border-light: #e9ecef; + --color-border-light: var(--color-border-subtle); --color-border-input: #d1d5db; - - /* Accent colors */ - --color-accent: #3b82f6; - --color-accent-hover: #2563eb; - - /* Status colors (remain consistent) */ - --color-success: #22c55e; - --color-warning: #f59e0b; - --color-error: #dc2626; + --color-success: var(--color-healthy); + --color-error: var(--color-critical); --color-error-bg: #fef2f2; --color-error-border: #fecaca; - - /* Spinner */ --color-spinner-track: #e9ecef; --color-spinner-fill: #1a1a2e; - - /* Chart colors */ - --color-chart-min: #22c55e; - --color-chart-avg: #3b82f6; + --color-chart-min: var(--color-healthy); + --color-chart-avg: var(--color-accent); --color-chart-max: #ef4444; - - /* Shadow */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); - --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25); } +/* --- Dark theme --- */ [data-theme="dark"] { - /* Primary colors */ + /* Surfaces */ + --color-bg: #0f1117; + --color-surface: #1a1d2e; + --color-surface-hover: #252840; + --color-border: #2d3148; + --color-border-subtle: #1e2235; + + /* Text */ + --color-text: #e2e8f0; + --color-text-secondary: #94a3b8; + --color-text-muted: #64748b; + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); + + /* --- Legacy aliases --- */ --color-primary: #2d2d4a; --color-primary-hover: #3d3d5a; - - /* Background colors */ - --color-bg-page: #121218; - --color-bg-card: #1e1e2e; + --color-bg-page: var(--color-bg); + --color-bg-card: var(--color-surface); --color-bg-sidebar: #1a1a28; --color-bg-input: #2a2a3a; - --color-bg-hover: #2a2a3a; + --color-bg-hover: var(--color-surface-hover); --color-bg-active: #3a3a4a; - - /* Text colors */ - --color-text-primary: #e5e5e5; - --color-text-secondary: #a0a0a0; - --color-text-muted: #8b8b9b; - --color-text-heading: #f0f0f0; + --color-text-primary: var(--color-text); + --color-text-heading: var(--color-text); --color-text-inverse: #ffffff; - - /* Border colors */ - --color-border: #3a3a4a; - --color-border-light: #2a2a3a; + --color-border-light: var(--color-border-subtle); --color-border-input: #4a4a5a; - - /* Accent colors */ - --color-accent: #60a5fa; - --color-accent-hover: #3b82f6; - - /* Status colors (remain consistent but slightly adjusted for dark) */ --color-success: #34d399; - --color-warning: #fbbf24; --color-error: #f87171; --color-error-bg: #2d1f1f; --color-error-border: #5c2a2a; - - /* Spinner */ --color-spinner-track: #3a3a4a; --color-spinner-fill: #60a5fa; - - /* Chart colors */ --color-chart-min: #34d399; --color-chart-avg: #60a5fa; --color-chart-max: #f87171; - - /* Shadow */ - --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.5); } +/* ============================================================= + Global Resets & Defaults + ============================================================= */ + * { box-sizing: border-box; margin: 0; @@ -108,13 +152,18 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, - Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: var(--font-base); + line-height: var(--line-height-normal); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background-color: var(--color-bg-page); - color: var(--color-text-primary); - transition: background-color 0.2s, color 0.2s; + background-color: var(--color-bg); + color: var(--color-text); + transition: background-color var(--duration-normal) ease, color var(--duration-normal) ease; +} + +code, pre, kbd, samp { + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace; } #root { @@ -123,14 +172,43 @@ body { flex-direction: column; } -/* Global loading states */ +/* --- Interactive element defaults --- */ + +button, a, [role="button"], select, label[for], +input[type="checkbox"], input[type="radio"] { + cursor: pointer; +} + +button, input, select, textarea { + font-family: inherit; + font-size: inherit; +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* --- Reduced motion --- */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +/* ============================================================= + Global Loading States + ============================================================= */ + .loading-container { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; - gap: 1rem; + gap: var(--space-4); color: var(--color-text-muted); } diff --git a/client/src/styles/shared.module.css b/client/src/styles/shared.module.css new file mode 100644 index 0000000..07789f1 --- /dev/null +++ b/client/src/styles/shared.module.css @@ -0,0 +1,137 @@ +/* ============================================================= + Shared CSS Module Classes + Composable building blocks for consistent UI patterns. + Components use these via CSS Modules `composes:` syntax. + ============================================================= */ + +/* --- Cards --- */ + +.card { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); +} + +.cardInteractive { + composes: card; + cursor: pointer; + transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease; +} +.cardInteractive:hover { + background: var(--color-surface-hover); +} + +/* --- Typography --- */ + +.sectionHeader { + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--color-text); + line-height: var(--line-height-tight); +} + +.label { + font-size: var(--font-xs); + font-weight: var(--font-medium); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.dataValue { + font-size: var(--font-base); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +/* --- Tables --- */ + +.tableRow { + height: 36px; + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; +} +.tableRow:hover { + background: var(--color-surface-hover); +} + +.tableHeader { + font-size: var(--font-xs); + font-weight: var(--font-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid var(--color-border); +} + +/* --- Inputs --- */ + +.input { + font-size: var(--font-base); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease; +} +.input:focus-visible { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +/* --- Buttons --- */ + +.buttonPrimary { + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + background: var(--color-accent); + color: #fff; + border: none; + cursor: pointer; + transition: background-color var(--duration-fast) ease; +} +.buttonPrimary:hover { + background: var(--color-accent-hover); +} + +.buttonGhost { + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + cursor: pointer; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; +} +.buttonGhost:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.buttonDanger { + composes: buttonPrimary; + background: var(--color-critical); +} +.buttonDanger:hover { + background: #b91c1c; +} + +/* --- Loading --- */ + +.skeleton { + background: var(--color-border-subtle); + border-radius: var(--radius-sm); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} From 60a71860e26ead9faf151e2e8403b5c77ede5892 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 20:24:53 -0700 Subject: [PATCH 02/21] =?UTF-8?q?DPS-68:=20Header=20&=20footer=20refinemen?= =?UTF-8?q?t=20=E2=80=94=20extract=20components,=20design=20tokens,=20pill?= =?UTF-8?q?=20theme=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract Header and Footer into standalone components from Layout.tsx - Footer: minimal 28px bar showing version injected via Vite define - Header: 48px slim bar with design token styling, user info pill badge - Theme toggle: pill-shaped sun/moon toggle using Lucide React icons - Add lucide-react dependency, declare __APP_VERSION__ global type - Update Layout.module.css to use design token transition durations - Update tests for new component structure --- client/package-lock.json | 10 + client/package.json | 1 + .../src/components/Layout/Footer.module.css | 12 + client/src/components/Layout/Footer.tsx | 11 + .../src/components/Layout/Header.module.css | 227 ++++++++++++++++++ client/src/components/Layout/Header.tsx | 57 +++++ .../src/components/Layout/Layout.module.css | 197 +-------------- client/src/components/Layout/Layout.test.tsx | 32 ++- client/src/components/Layout/Layout.tsx | 56 +---- client/src/vite-env.d.ts | 2 + client/tsconfig.node.json | 1 + client/vite.config.js | 4 + client/vite.config.ts | 4 + 13 files changed, 369 insertions(+), 245 deletions(-) create mode 100644 client/src/components/Layout/Footer.module.css create mode 100644 client/src/components/Layout/Footer.tsx create mode 100644 client/src/components/Layout/Header.module.css create mode 100644 client/src/components/Layout/Header.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 696ad46..76d1c4e 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "@xyflow/react": "^12.10.0", "dagre": "^0.8.5", "elkjs": "^0.11.0", + "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", @@ -7395,6 +7396,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/client/package.json b/client/package.json index 7436bb0..e303d87 100644 --- a/client/package.json +++ b/client/package.json @@ -17,6 +17,7 @@ "@xyflow/react": "^12.10.0", "dagre": "^0.8.5", "elkjs": "^0.11.0", + "lucide-react": "^0.577.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", diff --git a/client/src/components/Layout/Footer.module.css b/client/src/components/Layout/Footer.module.css new file mode 100644 index 0000000..e4e2999 --- /dev/null +++ b/client/src/components/Layout/Footer.module.css @@ -0,0 +1,12 @@ +.footer { + height: 28px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 var(--space-4); + border-top: 1px solid var(--color-border-subtle); + background-color: var(--color-surface); + font-size: var(--font-xs); + color: var(--color-text-muted); + flex-shrink: 0; +} diff --git a/client/src/components/Layout/Footer.tsx b/client/src/components/Layout/Footer.tsx new file mode 100644 index 0000000..0aebb0a --- /dev/null +++ b/client/src/components/Layout/Footer.tsx @@ -0,0 +1,11 @@ +import styles from './Footer.module.css'; + +function Footer() { + return ( +
+ Depsera v{__APP_VERSION__} +
+ ); +} + +export default Footer; diff --git a/client/src/components/Layout/Header.module.css b/client/src/components/Layout/Header.module.css new file mode 100644 index 0000000..7d56c01 --- /dev/null +++ b/client/src/components/Layout/Header.module.css @@ -0,0 +1,227 @@ +.header { + height: 48px; + background-color: var(--color-surface); + border-bottom: 1px solid var(--color-border); + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 var(--space-4); + position: sticky; + top: 0; + z-index: 100; + flex-shrink: 0; +} + +.headerLeft { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.menuButton { + display: none; + background: none; + border: none; + padding: var(--space-1); + color: var(--color-text-secondary); + transition: color var(--duration-fast) ease; +} + +.menuButton:hover { + color: var(--color-text); +} + +.menuIcon { + display: block; + width: 20px; + height: 2px; + background-color: currentColor; + position: relative; +} + +.menuIcon::before, +.menuIcon::after { + content: ''; + position: absolute; + width: 20px; + height: 2px; + background-color: currentColor; + left: 0; +} + +.menuIcon::before { + top: -6px; +} + +.menuIcon::after { + top: 6px; +} + +.logo { + width: 32px; + height: 32px; +} + +.titleImage { + height: 18px; + width: auto; +} + +.headerRight { + display: flex; + align-items: center; + gap: var(--space-3); +} + +.userInfo { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.userName { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); +} + +.userRole { + font-size: var(--font-xs); + color: var(--color-text-muted); + background-color: var(--color-surface-hover); + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + text-transform: capitalize; +} + +/* Theme Toggle — pill-shaped */ +.themeToggle { + display: flex; + align-items: center; + width: 56px; + height: 28px; + padding: 2px; + background-color: var(--color-surface-hover); + border: 1px solid var(--color-border); + border-radius: 14px; + cursor: pointer; + position: relative; + transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease; +} + +.themeToggle:hover { + border-color: var(--color-accent); +} + +.themeToggleTrack { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 100%; + position: relative; +} + +.themeToggleIcon { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + z-index: 1; + color: var(--color-text-muted); + transition: color var(--duration-normal) ease; +} + +.themeToggleIcon.active { + color: var(--color-accent); +} + +.themeToggleIndicator { + position: absolute; + width: 22px; + height: 22px; + border-radius: 50%; + background-color: var(--color-surface); + box-shadow: var(--shadow-sm); + transition: transform var(--duration-normal) ease; + top: 1px; + left: 1px; +} + +.themeToggleIndicator.dark { + transform: translateX(28px); +} + +.themeIcon { + width: 14px; + height: 14px; +} + +/* Logout Button */ +.logoutButton { + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + cursor: pointer; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; +} + +.logoutButton:hover { + background-color: var(--color-surface-hover); + color: var(--color-text); +} + +/* Responsive */ +@media (max-width: 768px) { + .menuButton { + display: flex; + align-items: center; + justify-content: center; + } + + .userName { + display: none; + } + + .userRole { + display: none; + } + + .headerRight { + gap: var(--space-2); + } + + .logoutButton { + padding: var(--space-1) var(--space-2); + } + + .themeToggle { + width: 48px; + height: 24px; + } + + .themeToggleIcon { + width: 18px; + height: 18px; + } + + .themeToggleIndicator { + width: 18px; + height: 18px; + } + + .themeToggleIndicator.dark { + transform: translateX(24px); + } + + .themeIcon { + width: 12px; + height: 12px; + } +} diff --git a/client/src/components/Layout/Header.tsx b/client/src/components/Layout/Header.tsx new file mode 100644 index 0000000..b68ef8b --- /dev/null +++ b/client/src/components/Layout/Header.tsx @@ -0,0 +1,57 @@ +import { Sun, Moon, LogOut } from 'lucide-react'; +import { useAuth } from '../../contexts/AuthContext'; +import { useTheme } from '../../contexts/ThemeContext'; +import styles from './Header.module.css'; + +interface HeaderProps { + onToggleSidebar: () => void; + onLogout: () => void; +} + +function Header({ onToggleSidebar, onLogout }: HeaderProps) { + const { user } = useAuth(); + const { theme, toggleTheme } = useTheme(); + + return ( +
+
+ + + Depsera +
+
+
+ {user?.name} + {user?.role} +
+ + +
+
+ ); +} + +export default Header; diff --git a/client/src/components/Layout/Layout.module.css b/client/src/components/Layout/Layout.module.css index 238e03d..3e18d28 100644 --- a/client/src/components/Layout/Layout.module.css +++ b/client/src/components/Layout/Layout.module.css @@ -1,138 +1,9 @@ .layout { - --app-header-height: 3.75rem; min-height: 100vh; display: flex; flex-direction: column; } -/* Header */ -.header { - background-color: var(--color-primary); - color: var(--color-text-inverse); - padding: 0.2rem; - box-shadow: var(--shadow-md); - display: flex; - justify-content: space-between; - align-items: center; - position: sticky; - top: 0; - z-index: 100; -} - -.headerLeft { - display: flex; - align-items: center; - gap: 1rem; -} - -.menuButton { - display: none; - background: none; - border: none; - padding: 0.5rem; - cursor: pointer; -} - -.menuIcon { - display: block; - width: 24px; - height: 2px; - background-color: var(--color-text-inverse); - position: relative; -} - -.menuIcon::before, -.menuIcon::after { - content: ''; - position: absolute; - width: 24px; - height: 2px; - background-color: var(--color-text-inverse); - left: 0; -} - -.menuIcon::before { - top: -7px; -} - -.menuIcon::after { - top: 7px; -} - -.logo { - width: 42px; - height: 42px; -} - -.titleImage { - height: 24px; - width: auto; -} - -.title { - font-size: 1.25rem; - font-weight: 600; -} - -.headerRight { - display: flex; - align-items: center; - gap: 1rem; -} - -.userName { - font-weight: 500; -} - -.userRole { - font-size: 0.75rem; - background-color: rgba(255, 255, 255, 0.15); - padding: 0.25rem 0.5rem; - border-radius: 4px; - text-transform: capitalize; -} - -/* Theme Toggle */ -.themeToggle { - display: flex; - align-items: center; - justify-content: center; - width: 2.25rem; - height: 2.25rem; - background-color: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 0.375rem; - cursor: pointer; - transition: background-color 0.2s, border-color 0.2s; - color: var(--color-text-inverse); -} - -.themeToggle:hover { - background-color: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.3); -} - -.themeIcon { - width: 1.25rem; - height: 1.25rem; -} - -.logoutButton { - background-color: transparent; - color: var(--color-text-inverse); - border: 1px solid rgba(255, 255, 255, 0.3); - padding: 0.5rem 1rem; - border-radius: 4px; - cursor: pointer; - font-size: 0.875rem; - transition: background-color 0.2s, border-color 0.2s; -} - -.logoutButton:hover { - background-color: rgba(255, 255, 255, 0.1); - border-color: rgba(255, 255, 255, 0.5); -} - /* Container with sidebar and main */ .container { flex: 1; @@ -146,7 +17,7 @@ background-color: var(--color-bg-sidebar); border-right: 1px solid var(--color-border-light); flex-shrink: 0; - transition: width 0.2s ease-in-out, background-color 0.2s, border-color 0.2s; + transition: width var(--duration-normal) ease-in-out, background-color var(--duration-normal), border-color var(--duration-normal); position: relative; display: flex; flex-direction: column; @@ -170,7 +41,7 @@ align-items: center; justify-content: center; z-index: 10; - transition: background-color 0.2s, border-color 0.2s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); box-shadow: var(--shadow-sm); } @@ -182,7 +53,7 @@ width: 14px; height: 14px; color: var(--color-text-muted); - transition: transform 0.2s; + transition: transform var(--duration-normal); } .collapseIcon.collapsed { @@ -204,7 +75,7 @@ color: var(--color-text-muted); text-decoration: none; font-weight: 500; - transition: background-color 0.2s, color 0.2s; + transition: background-color var(--duration-fast), color var(--duration-fast); white-space: nowrap; overflow: hidden; } @@ -215,7 +86,7 @@ } .navLinkText { - transition: opacity 0.2s; + transition: opacity var(--duration-fast); } .sidebarCollapsed .navLinkText { @@ -273,20 +144,11 @@ width: 100%; overflow: auto; background-color: var(--color-bg-page); - transition: background-color 0.2s; + transition: background-color var(--duration-normal); display: flex; flex-direction: column; } -/* Footer */ -.footer { - background-color: var(--color-primary); - color: var(--color-text-inverse); - padding: 1rem 2rem; - text-align: center; - font-size: 0.875rem; -} - /* Mobile overlay */ .overlay { display: none; @@ -294,18 +156,6 @@ /* Responsive styles */ @media (max-width: 768px) { - .menuButton { - display: block; - } - - .title { - font-size: 1rem; - } - - .userName { - display: none; - } - .sidebar { position: fixed; top: 0; @@ -313,7 +163,7 @@ height: 100vh; z-index: 200; transform: translateX(-100%); - transition: transform 0.3s ease-in-out; + transition: transform var(--duration-slow) ease-in-out; padding-top: 60px; } @@ -328,37 +178,4 @@ background-color: rgba(0, 0, 0, 0.5); z-index: 150; } - - .main { - padding: 0; - } - - .headerRight { - gap: 0.5rem; - } - - .logoutButton { - padding: 0.375rem 0.75rem; - font-size: 0.8rem; - } - - .themeToggle { - width: 2rem; - height: 2rem; - } - - .themeIcon { - width: 1rem; - height: 1rem; - } -} - -@media (max-width: 480px) { - .header { - padding: 0.75rem 1rem; - } - - .userRole { - display: none; - } } diff --git a/client/src/components/Layout/Layout.test.tsx b/client/src/components/Layout/Layout.test.tsx index 9569faf..5006421 100644 --- a/client/src/components/Layout/Layout.test.tsx +++ b/client/src/components/Layout/Layout.test.tsx @@ -4,6 +4,16 @@ import Layout from './Layout'; import { AuthProvider } from './../../contexts/AuthContext'; import { ThemeProvider } from './../../contexts/ThemeContext'; +// Mock lucide-react icons +jest.mock('lucide-react', () => ({ + Sun: (props: Record) => , + Moon: (props: Record) => , + LogOut: (props: Record) => , +})); + +// Mock __APP_VERSION__ +(globalThis as Record).__APP_VERSION__ = '1.0.0'; + const mockFetch = jest.fn(); global.fetch = mockFetch; @@ -253,7 +263,20 @@ describe('Layout', () => { expect(await screen.findByText('Dashboard Content')).toBeInTheDocument(); }); - it('renders footer with copyright', async () => { + it('renders footer with version', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ id: '1', name: 'Test User', role: 'user' }), + }); + + renderLayout(); + + await screen.findByText('Test User'); + + expect(screen.getByText('Depsera v1.0.0')).toBeInTheDocument(); + }); + + it('renders theme toggle with sun and moon icons', async () => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: '1', name: 'Test User', role: 'user' }), @@ -263,8 +286,8 @@ describe('Layout', () => { await screen.findByText('Test User'); - const year = new Date().getFullYear(); - expect(screen.getByText(`© ${year} Depsera`)).toBeInTheDocument(); + expect(screen.getByTestId('sun-icon')).toBeInTheDocument(); + expect(screen.getByTestId('moon-icon')).toBeInTheDocument(); }); it('handles logout', async () => { @@ -282,7 +305,8 @@ describe('Layout', () => { await screen.findByText('Test User'); - fireEvent.click(screen.getByText('Logout')); + const logoutButton = screen.getByTestId('logout-icon').closest('button'); + fireEvent.click(logoutButton!); await waitFor(() => { expect(mockLocation.href).toBe('/login'); diff --git a/client/src/components/Layout/Layout.tsx b/client/src/components/Layout/Layout.tsx index 82e19f0..23bf3b6 100644 --- a/client/src/components/Layout/Layout.tsx +++ b/client/src/components/Layout/Layout.tsx @@ -1,14 +1,14 @@ import { useState } from 'react'; import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; -import { useTheme } from '../../contexts/ThemeContext'; +import Header from './Header'; +import Footer from './Footer'; import styles from './Layout.module.css'; const SIDEBAR_COLLAPSED_KEY = 'sidebar-collapsed'; function Layout() { - const { user, isAdmin, logout } = useAuth(); - const { theme, toggleTheme } = useTheme(); + const { isAdmin, logout } = useAuth(); const navigate = useNavigate(); const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { @@ -37,51 +37,7 @@ function Layout() { return (
-
-
- - - Depsera - {/*

Depsera

*/} -
-
- {user?.name} - {user?.role} - - -
-
+
{sidebarOpen && ( @@ -273,9 +229,7 @@ function Layout() {
-
-

© {new Date().getFullYear()} Depsera

-
+
); } diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index 40abd8c..9b084e2 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __APP_VERSION__: string; + declare module '*.module.css' { const classes: { readonly [key: string]: string }; export default classes; diff --git a/client/tsconfig.node.json b/client/tsconfig.node.json index 97ede7e..35fa7d2 100644 --- a/client/tsconfig.node.json +++ b/client/tsconfig.node.json @@ -5,6 +5,7 @@ "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, "strict": true }, "include": ["vite.config.ts"] diff --git a/client/vite.config.js b/client/vite.config.js index 25ba9b0..57e5c11 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -1,7 +1,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import pkg from './package.json'; export default defineConfig({ plugins: [react()], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, server: { port: 3000, proxy: { diff --git a/client/vite.config.ts b/client/vite.config.ts index 981bb83..e3ff0f4 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,8 +1,12 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import pkg from './package.json'; export default defineConfig({ plugins: [react()], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, server: { port: 3000, proxy: { From b97db9a59184e1f183af97bb1c0ae8fa2bf7438b Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 20:29:27 -0700 Subject: [PATCH 03/21] =?UTF-8?q?DPS-73:=20Modal=20&=20ConfirmDialog=20vis?= =?UTF-8?q?ual=20polish=20=E2=80=94=20design=20tokens,=20Lucide=20icons,?= =?UTF-8?q?=20size=20variants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle Modal with frosted backdrop blur, open animation, Lucide X close button, and standardized size variants (sm/md/lg). Update ConfirmDialog CSS to use design tokens. Migrate all Modal consumers to new size prop values. --- .../common/ConfirmDialog.module.css | 53 ++++++------ .../src/components/common/ConfirmDialog.tsx | 2 +- client/src/components/common/Modal.module.css | 82 +++++++++++++------ client/src/components/common/Modal.test.tsx | 17 +++- client/src/components/common/Modal.tsx | 17 +--- .../pages/Services/DependencyEditModal.tsx | 2 +- .../pages/Services/ServiceDetail.tsx | 2 +- .../pages/Services/ServicesList.tsx | 2 +- .../src/components/pages/Teams/TeamDetail.tsx | 2 +- .../src/components/pages/Teams/TeamsList.tsx | 2 +- 10 files changed, 108 insertions(+), 73 deletions(-) diff --git a/client/src/components/common/ConfirmDialog.module.css b/client/src/components/common/ConfirmDialog.module.css index e8c16f4..7bc94ea 100644 --- a/client/src/components/common/ConfirmDialog.module.css +++ b/client/src/components/common/ConfirmDialog.module.css @@ -1,36 +1,37 @@ .content { display: flex; flex-direction: column; - gap: 1.5rem; + gap: var(--space-5); } .message { color: var(--color-text-secondary); margin: 0; - line-height: 1.5; + line-height: var(--line-height-normal); + font-size: var(--font-base); } .actions { display: flex; justify-content: flex-end; - gap: 0.75rem; + gap: var(--space-3); } .cancelButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + color: var(--color-text-secondary); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .cancelButton:hover:not(:disabled) { - background-color: var(--color-bg-hover); - border-color: var(--color-text-muted); + background-color: var(--color-surface-hover); + color: var(--color-text); } .cancelButton:disabled { @@ -39,19 +40,19 @@ } .confirmButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); - background-color: var(--color-accent); - border: 1px solid transparent; - border-radius: 0.375rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + color: #fff; + background: var(--color-accent); + border: none; + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease; } .confirmButton:hover:not(:disabled) { - background-color: var(--color-accent-hover); + background: var(--color-accent-hover); } .confirmButton:disabled { @@ -60,13 +61,9 @@ } .confirmButton.destructive { - background-color: var(--color-error); + background: var(--color-critical); } .confirmButton.destructive:hover:not(:disabled) { - background-color: #dc2626; -} - -[data-theme="dark"] .confirmButton.destructive:hover:not(:disabled) { - background-color: #b91c1c; + background: #b91c1c; } diff --git a/client/src/components/common/ConfirmDialog.tsx b/client/src/components/common/ConfirmDialog.tsx index 1cda42e..d86a2fa 100644 --- a/client/src/components/common/ConfirmDialog.tsx +++ b/client/src/components/common/ConfirmDialog.tsx @@ -25,7 +25,7 @@ function ConfirmDialog({ isLoading = false, }: ConfirmDialogProps) { return ( - +

{message}

diff --git a/client/src/components/common/Modal.module.css b/client/src/components/common/Modal.module.css index 61d0dfa..19b5de3 100644 --- a/client/src/components/common/Modal.module.css +++ b/client/src/components/common/Modal.module.css @@ -1,6 +1,8 @@ +/* --- Modal dialog --- */ + .modal { border: none; - border-radius: 0.5rem; + border-radius: var(--radius-lg); padding: 0; box-shadow: var(--shadow-lg); max-height: 85vh; @@ -8,30 +10,55 @@ position: fixed; top: 50%; left: 50%; - transform: translate(-50%, -50%); margin: 0; - background-color: var(--color-bg-card); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + z-index: var(--z-modal); + + /* Open animation */ + animation: modalIn var(--duration-normal) ease; +} + +@keyframes modalIn { + from { + opacity: 0; + transform: translate(-50%, calc(-50% + 8px)); + } + to { + opacity: 1; + transform: translate(-50%, -50%); + } +} + +/* Final resting position (animation fill-mode not needed — these are the defaults) */ +.modal { + transform: translate(-50%, -50%); } .modal::backdrop { background-color: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); } -.small { - width: 24rem; +/* --- Size variants --- */ + +.sm { + width: 400px; max-width: 90vw; } -.medium { - width: 32rem; +.md { + width: 560px; max-width: 90vw; } -.large { - width: 48rem; +.lg { + width: 720px; max-width: 90vw; } +/* --- Content layout --- */ + .content { display: flex; flex-direction: column; @@ -42,43 +69,52 @@ display: flex; align-items: center; justify-content: space-between; - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4) var(--space-5); } .title { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; + line-height: var(--line-height-tight); } .closeButton { display: flex; align-items: center; justify-content: center; - width: 2rem; - height: 2rem; + width: 28px; + height: 28px; border: none; background: none; - border-radius: 0.375rem; + border-radius: var(--radius-sm); color: var(--color-text-muted); cursor: pointer; - transition: background-color 0.15s, color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; + flex-shrink: 0; } .closeButton:hover { - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background-color: var(--color-surface-hover); + color: var(--color-text); } -.closeButton:focus { +.closeButton:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } .body { - padding: 1.5rem; + padding: 0 var(--space-5) var(--space-5); overflow-y: auto; - color: var(--color-text-primary); + color: var(--color-text); +} + +/* --- Reduced motion --- */ + +@media (prefers-reduced-motion: reduce) { + .modal { + animation: none; + } } diff --git a/client/src/components/common/Modal.test.tsx b/client/src/components/common/Modal.test.tsx index 6b22c9d..f05b736 100644 --- a/client/src/components/common/Modal.test.tsx +++ b/client/src/components/common/Modal.test.tsx @@ -103,15 +103,26 @@ describe('Modal', () => { expect(onClose).toHaveBeenCalled(); }); - it('applies size class', () => { + it('defaults to md size class', () => { render( - {}} title="Test Modal" size="large"> + {}} title="Test Modal"> +

Modal content

+
+ ); + + const dialog = screen.getByRole('dialog', { hidden: true }); + expect(dialog.className).toContain('md'); + }); + + it.each(['sm', 'md', 'lg'] as const)('applies %s size class', (size) => { + render( + {}} title="Test Modal" size={size}>

Modal content

); const dialog = screen.getByRole('dialog', { hidden: true }); - expect(dialog.className).toContain('large'); + expect(dialog.className).toContain(size); }); it('calls close when transitioning from open to closed', () => { diff --git a/client/src/components/common/Modal.tsx b/client/src/components/common/Modal.tsx index 6d2d799..8ad32d5 100644 --- a/client/src/components/common/Modal.tsx +++ b/client/src/components/common/Modal.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, type ReactNode } from 'react'; +import { X } from 'lucide-react'; import styles from './Modal.module.css'; interface ModalProps { @@ -6,10 +7,10 @@ interface ModalProps { onClose: () => void; title: string; children: ReactNode; - size?: 'small' | 'medium' | 'large'; + size?: 'sm' | 'md' | 'lg'; } -function Modal({ isOpen, onClose, title, children, size = 'medium' }: ModalProps) { +function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) { const dialogRef = useRef(null); const previousFocusRef = useRef(null); @@ -53,7 +54,6 @@ function Modal({ isOpen, onClose, title, children, size = 'medium' }: ModalProps return ( - - - +
{children}
diff --git a/client/src/components/pages/Services/DependencyEditModal.tsx b/client/src/components/pages/Services/DependencyEditModal.tsx index 852d5a5..cc5d1e2 100644 --- a/client/src/components/pages/Services/DependencyEditModal.tsx +++ b/client/src/components/pages/Services/DependencyEditModal.tsx @@ -165,7 +165,7 @@ function DependencyEditModal({ isOpen={dep !== null} onClose={onClose} title={`Edit — ${dep.canonical_name || dep.name}`} - size="large" + size="lg" > {/* Overrides Section */}
diff --git a/client/src/components/pages/Services/ServiceDetail.tsx b/client/src/components/pages/Services/ServiceDetail.tsx index d3a599d..909043e 100644 --- a/client/src/components/pages/Services/ServiceDetail.tsx +++ b/client/src/components/pages/Services/ServiceDetail.tsx @@ -339,7 +339,7 @@ function ServiceDetail() { isOpen={isEditModalOpen} onClose={() => setIsEditModalOpen(false)} title="Edit Service" - size="medium" + size="md" > setIsAddModalOpen(false)} title="Add Service" - size="medium" + size="md" > setIsEditModalOpen(false)} title="Edit Team" - size="medium" + size="md" > setIsAddModalOpen(false)} title="Create Team" - size="medium" + size="md" > Date: Thu, 5 Mar 2026 20:35:33 -0700 Subject: [PATCH 04/21] =?UTF-8?q?DPS-69:=20Dashboard=20layout=20stability?= =?UTF-8?q?=20&=20polish=20=E2=80=94=20stable=20CSS=20Grid,=20design=20tok?= =?UTF-8?q?ens,=20skeleton=20loading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stable CSS Grid with named areas prevents layout shift when sections appear/disappear - Polling issues section always renders with empty state instead of conditionally removed - Summary cards use border-left status color accent with design tokens - Unstable deps bars colored by health status (not meaningless yellow) - Background refreshes use opacity fade instead of replacing content - Initial load shows skeleton placeholders matching grid layout - Health overview always renders, shows empty state when no services - Recent activity uses minimal dot timeline instead of large icon circles - All spacing, typography, colors use design system tokens - Updated tests for new loading skeleton and always-rendered sections --- .../pages/Dashboard/Dashboard.module.css | 573 +++++++++++------- .../pages/Dashboard/Dashboard.test.tsx | 40 +- .../components/pages/Dashboard/Dashboard.tsx | 373 ++++++------ 3 files changed, 563 insertions(+), 423 deletions(-) diff --git a/client/src/components/pages/Dashboard/Dashboard.module.css b/client/src/components/pages/Dashboard/Dashboard.module.css index f8d44d6..88b2ecc 100644 --- a/client/src/components/pages/Dashboard/Dashboard.module.css +++ b/client/src/components/pages/Dashboard/Dashboard.module.css @@ -1,36 +1,50 @@ +/* ============================================================= + Dashboard — Swiss Modernism 2.0 + Stable CSS Grid layout with named areas, design tokens + ============================================================= */ + /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-5); display: flex; flex-direction: column; overflow: auto; } +/* Refreshing state — opacity fade for background refreshes */ +.refreshing { + opacity: 0.6; + transition: opacity var(--duration-normal) ease; +} + +/* --- Header --- */ + .header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .titleRow { display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); + line-height: var(--line-height-tight); margin: 0; } .headerActions { display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); } .refreshingIndicator { @@ -47,20 +61,21 @@ animation: spin 1s linear infinite; } -/* Auto-refresh controls */ +/* --- Auto-refresh controls --- */ + .autoRefreshControls { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.75rem; - background-color: var(--color-bg-hover); + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); } .autoRefreshLabel { - font-size: 0.8125rem; - font-weight: 500; + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); white-space: nowrap; } @@ -71,21 +86,21 @@ width: 2.5rem; height: 1.5rem; padding: 0; - background-color: var(--color-border-input); + background-color: var(--color-border); border: none; border-radius: 9999px; cursor: pointer; - transition: background-color 0.2s ease; + transition: background-color var(--duration-normal) ease; flex-shrink: 0; } -.togglePill:focus { +.togglePill:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } .togglePill.toggleActive { - background-color: var(--color-success); + background-color: var(--color-healthy); } .toggleKnob { @@ -94,10 +109,10 @@ left: 2px; width: 1.25rem; height: 1.25rem; - background-color: var(--color-bg-card); + background-color: #fff; border-radius: 50%; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: transform 0.2s ease; + transition: transform var(--duration-normal) ease; } .togglePill.toggleActive .toggleKnob { @@ -106,25 +121,26 @@ /* Interval dropdown */ .intervalSelect { - padding: 0.25rem 1.5rem 0.25rem 0.5rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-input); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-1) var(--space-5) var(--space-1) var(--space-2); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); background-position: right 0.25rem center; background-repeat: no-repeat; background-size: 1.25rem 1.25rem; - transition: border-color 0.15s, opacity 0.15s; + transition: border-color var(--duration-fast) ease; } -.intervalSelect:focus { +.intervalSelect:focus-visible { outline: none; border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .intervalSelect:disabled { @@ -132,111 +148,137 @@ cursor: not-allowed; } -/* Summary Cards Grid */ +/* --- Dashboard Grid --- */ + +.dashboard { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: + "summary summary" + "health health" + "issues activity" + "teams unstable" + "polling polling"; + gap: var(--space-4); +} + +.areaSummary { grid-area: summary; } +.areaHealth { grid-area: health; } +.areaIssues { grid-area: issues; } +.areaActivity { grid-area: activity; } +.areaTeams { grid-area: teams; } +.areaUnstable { grid-area: unstable; } +.areaPolling { grid-area: polling; } + +/* --- Summary Cards Grid --- */ + .summaryGrid { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-4); } .summaryCard { - background-color: var(--color-bg-card); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.25rem; + border-radius: var(--radius-md); + padding: var(--space-4); display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--space-2); + border-left: 3px solid var(--color-unknown); + transition: background-color var(--duration-fast) ease; } -.summaryCard.clickable { - cursor: pointer; - transition: border-color 0.15s, box-shadow 0.15s; +.summaryCard:hover { + background: var(--color-surface-hover); } -.summaryCard.clickable:hover { - border-color: var(--color-accent); - box-shadow: var(--shadow-sm); +.summaryCardTotal { + composes: summaryCard; + border-left-color: var(--color-accent); + cursor: pointer; } -.cardLabel { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-muted); +.summaryCardHealthy { + composes: summaryCard; + border-left-color: var(--color-healthy); } -.cardValue { - font-size: 2rem; - font-weight: 700; - font-variant-numeric: tabular-nums; - color: var(--color-text-heading); - line-height: 1; +.summaryCardWarning { + composes: summaryCard; + border-left-color: var(--color-warning); } -.cardValue.healthy { - color: var(--color-success); +.summaryCardCritical { + composes: summaryCard; + border-left-color: var(--color-critical); } -.cardValue.warning { - color: var(--color-warning); +.cardLabel { + font-size: var(--font-xs); + font-weight: var(--font-medium); + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted); } -.cardValue.critical { - color: var(--color-error); +.cardValue { + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + font-variant-numeric: tabular-nums; + color: var(--color-text); + line-height: var(--line-height-tight); } .cardSubtext { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } -/* Health Overview */ +/* --- Health Overview --- */ + .healthOverview { - background-color: var(--color-bg-card); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.25rem; - margin-bottom: 1.5rem; + border-radius: var(--radius-md); + padding: var(--space-4); } .healthOverviewHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 0.75rem; + margin-bottom: var(--space-3); } .healthOverviewTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } .healthOverviewSubtitle { - font-size: 0.8125rem; - font-weight: 500; + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); } .healthBar { display: flex; - height: 1.25rem; - border-radius: 0.375rem; + height: 1rem; overflow: hidden; - background-color: var(--color-bg-hover); + background-color: var(--color-border-subtle); } .healthSegment { - transition: width 0.3s ease; + transition: width var(--duration-slow) ease; min-width: 2px; } .segmentHealthy { - background-color: var(--color-success); + background-color: var(--color-healthy); } .segmentWarning { @@ -244,86 +286,80 @@ } .segmentCritical { - background-color: var(--color-error); + background-color: var(--color-critical); } .segmentUnknown { - background-color: var(--color-text-muted); - opacity: 0.3; + background-color: var(--color-unknown); + opacity: 0.4; } .healthLegend { display: flex; - gap: 1rem; - margin-top: 0.625rem; + gap: var(--space-4); + margin-top: var(--space-2); } .healthLegendItem { display: flex; align-items: center; - gap: 0.375rem; - font-size: 0.75rem; + gap: var(--space-1); + font-size: var(--font-xs); color: var(--color-text-muted); } .healthLegendDot { - width: 0.5rem; - height: 0.5rem; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; } -/* Section Layout */ -.sectionsGrid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.5rem; - margin-bottom: 1.5rem; -} +/* --- Section Cards --- */ .section { - background-color: var(--color-bg-card); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); overflow: hidden; -} - -.sectionFullWidth { - grid-column: 1 / -1; + display: flex; + flex-direction: column; } .sectionHeader { display: flex; justify-content: space-between; align-items: center; - padding: 1rem 1.25rem; - border-bottom: 1px solid var(--color-border); - background-color: var(--color-bg-hover); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } .sectionTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } .sectionLink { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); + transition: color var(--duration-fast) ease; } .sectionLink:hover { - text-decoration: underline; + color: var(--color-accent-hover); } .sectionContent { padding: 0; + flex: 1; } -/* Issues List */ +/* --- Issues List (Services with Issues) --- */ + .issuesList { list-style: none; margin: 0; @@ -333,10 +369,10 @@ .issueItem { display: flex; align-items: center; - gap: 0.75rem; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); - transition: background-color 0.15s; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; } .issueItem:last-child { @@ -344,38 +380,38 @@ } .issueItem:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .issueLink { flex: 1; display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); text-decoration: none; color: inherit; } .issueName { - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); } .issueTeam { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } .issueStats { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.75rem; + font-size: var(--font-xs); + font-variant-numeric: tabular-nums; color: var(--color-text-muted); + white-space: nowrap; } -/* Team Health List */ +/* --- Team Health List --- */ + .teamList { list-style: none; margin: 0; @@ -386,9 +422,9 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); - transition: background-color 0.15s; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; } .teamItem:last-child { @@ -396,7 +432,7 @@ } .teamItem:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .teamLink { @@ -406,45 +442,35 @@ } .teamName { - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); } .teamStats { display: flex; align-items: center; - gap: 1rem; + gap: var(--space-3); } .teamStat { display: flex; align-items: center; - gap: 0.25rem; - font-size: 0.75rem; + gap: var(--space-1); + font-size: var(--font-xs); font-variant-numeric: tabular-nums; -} - -.teamStat.healthy { - color: var(--color-success); -} - -.teamStat.warning { - color: var(--color-warning); -} - -.teamStat.critical { - color: var(--color-error); + color: var(--color-text-muted); } .teamStatDot { - width: 0.5rem; - height: 0.5rem; + width: 8px; + height: 8px; border-radius: 50%; + flex-shrink: 0; } .teamStatDot.healthy { - background-color: var(--color-success); + background-color: var(--color-healthy); } .teamStatDot.warning { @@ -452,10 +478,11 @@ } .teamStatDot.critical { - background-color: var(--color-error); + background-color: var(--color-critical); } -/* Recent Activity List */ +/* --- Recent Activity --- */ + .activityList { list-style: none; margin: 0; @@ -464,39 +491,30 @@ .activityItem { display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } .activityItem:last-child { border-bottom: none; } -.activityIcon { - width: 2rem; - height: 2rem; +.activityDot { + width: 8px; + height: 8px; border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; flex-shrink: 0; + margin-top: 6px; } -.activityIcon.healthy { - background-color: rgba(34, 197, 94, 0.1); - color: var(--color-success); +.activityDot.healthy { + background-color: var(--color-healthy); } -.activityIcon.warning { - background-color: rgba(245, 158, 11, 0.1); - color: var(--color-warning); -} - -.activityIcon.critical { - background-color: rgba(220, 38, 38, 0.1); - color: var(--color-error); +.activityDot.critical { + background-color: var(--color-critical); } .activityContent { @@ -505,27 +523,29 @@ } .activityText { - font-size: 0.875rem; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); + line-height: var(--line-height-normal); } .activityLink { color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); } .activityLink:hover { - text-decoration: underline; + color: var(--color-accent-hover); } .activityTime { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); - margin-top: 0.125rem; + margin-top: 2px; } -/* Unstable Dependencies */ +/* --- Unstable Dependencies --- */ + .unstableList { list-style: none; margin: 0; @@ -536,9 +556,9 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.875rem 1.25rem; - border-bottom: 1px solid var(--color-border); - transition: background-color 0.15s; + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; } .unstableItem:last-child { @@ -546,30 +566,30 @@ } .unstableItem:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .unstableInfo { display: flex; align-items: center; - gap: 0.625rem; + gap: var(--space-2); flex: 1; min-width: 0; } .unstableDot { - width: 0.5rem; - height: 0.5rem; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; } .unstableDot.healthy { - background-color: var(--color-success); + background-color: var(--color-healthy); } .unstableDot.critical { - background-color: var(--color-error); + background-color: var(--color-critical); } .unstableText { @@ -579,8 +599,8 @@ } .unstableName { - font-size: 0.875rem; - font-weight: 500; + font-size: var(--font-base); + font-weight: var(--font-medium); color: var(--color-accent); text-decoration: none; white-space: nowrap; @@ -589,57 +609,76 @@ } .unstableName:hover { - text-decoration: underline; + color: var(--color-accent-hover); } .unstableService { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } .unstableBar { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 40%; flex-shrink: 0; } +.unstableBarTrack { + flex: 1; + height: 6px; + background-color: var(--color-border-subtle); + border-radius: var(--radius-sm); + overflow: hidden; +} + .unstableBarFill { - height: 0.375rem; + height: 100%; + border-radius: var(--radius-sm); + transition: width var(--duration-slow) ease; +} + +.unstableBarFill.healthy { + background-color: var(--color-healthy); +} + +.unstableBarFill.warning { background-color: var(--color-warning); - border-radius: 0.1875rem; - flex: 1; - transition: width 0.3s ease; +} + +.unstableBarFill.critical { + background-color: var(--color-critical); } .unstableCount { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-sm); + font-weight: var(--font-semibold); font-variant-numeric: tabular-nums; color: var(--color-text-muted); min-width: 1.5rem; text-align: right; } -/* Polling Issues */ +/* --- Polling Issues --- */ + .pollingIssuesBadge { display: inline-flex; align-items: center; justify-content: center; min-width: 1.25rem; height: 1.25rem; - padding: 0 0.375rem; - font-size: 0.6875rem; - font-weight: 700; + padding: 0 var(--space-1); + font-size: var(--font-xs); + font-weight: var(--font-semibold); border-radius: 9999px; background-color: var(--color-warning); color: #fff; } .pollingDot { - width: 0.5rem; - height: 0.5rem; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; } @@ -649,31 +688,37 @@ } .pollingDot.critical { - background-color: var(--color-error); + background-color: var(--color-critical); } .pollingIssueDetail { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); white-space: nowrap; } -/* Empty State */ +/* --- Empty State --- */ + .emptySection { - padding: 2rem 1.25rem; + padding: var(--space-6) var(--space-4); text-align: center; color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); } -/* Loading State */ +/* --- Loading / Skeleton States --- */ + .loading { display: flex; flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-6); color: var(--color-text-muted); } @@ -692,47 +737,113 @@ } } -/* Error State */ +.skeletonCard { + background: var(--color-border-subtle); + border-radius: var(--radius-sm); + animation: pulse 1.5s ease-in-out infinite; +} + +.skeletonLine { + height: 12px; + background: var(--color-border-subtle); + border-radius: var(--radius-sm); + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Skeleton grid layout for initial load */ +.skeletonDashboard { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: + "summary summary" + "health health" + "issues activity" + "teams unstable" + "polling polling"; + gap: var(--space-4); +} + +.skeletonSummaryGrid { + grid-area: summary; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); +} + +.skeletonSummaryCard { + composes: skeletonCard; + height: 96px; +} + +.skeletonSection { + composes: skeletonCard; + height: 200px; +} + +.skeletonHealthBar { + composes: skeletonCard; + grid-area: health; + height: 80px; +} + +/* --- Error State --- */ + .error { display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-6); + color: var(--color-critical); text-align: center; } .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + color: var(--color-text-secondary); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); + color: var(--color-text); } -/* Responsive */ +/* --- Responsive --- */ + @media (max-width: 1024px) { .summaryGrid { grid-template-columns: repeat(2, 1fr); } - .sectionsGrid { + .dashboard, + .skeletonDashboard { grid-template-columns: 1fr; + grid-template-areas: + "summary" + "health" + "issues" + "activity" + "teams" + "unstable" + "polling"; } } @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .summaryGrid { @@ -742,7 +853,7 @@ .header { flex-direction: column; align-items: flex-start; - gap: 1rem; + gap: var(--space-3); } .headerActions { @@ -751,6 +862,6 @@ } .cardValue { - font-size: 1.5rem; + font-size: var(--font-xl); } } diff --git a/client/src/components/pages/Dashboard/Dashboard.test.tsx b/client/src/components/pages/Dashboard/Dashboard.test.tsx index 24bdf45..837bc9d 100644 --- a/client/src/components/pages/Dashboard/Dashboard.test.tsx +++ b/client/src/components/pages/Dashboard/Dashboard.test.tsx @@ -87,12 +87,14 @@ beforeEach(() => { }); describe('Dashboard', () => { - it('shows loading state initially', () => { + it('shows loading skeleton initially', () => { mockFetch.mockImplementation(() => new Promise(() => {})); - renderDashboard(); + const { container } = renderDashboard(); - expect(screen.getByText('Loading dashboard...')).toBeInTheDocument(); + // Skeleton dashboard is rendered during loading + expect(container.querySelector('[class*="skeletonDashboard"]')).toBeInTheDocument(); + expect(container.querySelectorAll('[class*="skeletonSummaryCard"]').length).toBe(4); }); it('renders dashboard content after loading', async () => { @@ -101,11 +103,10 @@ describe('Dashboard', () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Total Services')).toBeInTheDocument(); }); // Summary stats - expect(screen.getByText('Total Services')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); // Total expect(screen.getByText('2 teams')).toBeInTheDocument(); }); @@ -133,7 +134,7 @@ describe('Dashboard', () => { fireEvent.click(screen.getByText('Retry')); await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Total Services')).toBeInTheDocument(); }); }); @@ -232,8 +233,8 @@ describe('Dashboard', () => { expect(screen.getByText('Total Services')).toBeInTheDocument(); }); - // Find and click the clickable card - const card = screen.getByText('Total Services').closest('[class*="summaryCard"]'); + // Find and click the clickable total card + const card = screen.getByText('Total Services').closest('[class*="summaryCardTotal"]'); fireEvent.click(card!); expect(mockNavigate).toHaveBeenCalledWith('/services'); @@ -245,8 +246,10 @@ describe('Dashboard', () => { renderDashboard(); await waitFor(() => { - expect(screen.getByText('No teams with services')).toBeInTheDocument(); + expect(screen.getByText('Health by Team')).toBeInTheDocument(); }); + + expect(screen.getByText('No teams with services')).toBeInTheDocument(); }); it('shows no activity message when empty', async () => { @@ -282,6 +285,18 @@ describe('Dashboard', () => { expect(screen.getAllByText('Service B').length).toBeGreaterThan(0); }); + it('always renders polling issues section with empty state', async () => { + mockDashboardFetches(mockServices, mockTeams); + + renderDashboard(); + + await waitFor(() => { + expect(screen.getByText('Polling Issues')).toBeInTheDocument(); + }); + + expect(screen.getByText('No polling issues')).toBeInTheDocument(); + }); + describe('Health Overview', () => { it('displays health overview bar when services exist', async () => { mockDashboardFetches(mockServices, mockTeams); @@ -321,16 +336,17 @@ describe('Dashboard', () => { expect(screen.getByText('Critical (1)')).toBeInTheDocument(); }); - it('does not show health overview when no services', async () => { + it('shows empty state in health overview when no services', async () => { mockDashboardFetches([], []); renderDashboard(); await waitFor(() => { - expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Health Overview')).toBeInTheDocument(); }); - expect(screen.queryByText('Health Overview')).not.toBeInTheDocument(); + expect(screen.getByText('No services registered')).toBeInTheDocument(); + expect(screen.queryByRole('img', { name: 'Health distribution bar' })).not.toBeInTheDocument(); }); it('shows 100% healthy when all services are healthy', async () => { diff --git a/client/src/components/pages/Dashboard/Dashboard.tsx b/client/src/components/pages/Dashboard/Dashboard.tsx index d59cfb7..a2d1e75 100644 --- a/client/src/components/pages/Dashboard/Dashboard.tsx +++ b/client/src/components/pages/Dashboard/Dashboard.tsx @@ -38,9 +38,24 @@ function Dashboard() { if (isLoading) { return (
-
-
- Loading dashboard... +
+
+

Dashboard

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
); @@ -99,99 +114,103 @@ function Dashboard() {
- {/* Summary Cards */} -
-
navigate('/services')} - > - Total Services - {stats.total} - {teams.length} teams -
-
- Healthy - {stats.healthy} - - {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% of services - -
-
- Warning - {stats.warning} - need attention -
-
- Critical - {stats.critical} - require action + {/* Dashboard Grid */} +
+ {/* Summary Cards */} +
+
navigate('/services')} + > + Total Services + {stats.total} + {teams.length} teams +
+
+ Healthy + {stats.healthy} + + {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% of services + +
+
+ Warning + {stats.warning} + need attention +
+
+ Critical + {stats.critical} + require action +
-
- {/* Health Overview Bar */} - {stats.total > 0 && ( -
+ {/* Health Overview Bar */} +

Health Overview

- {Math.round((stats.healthy / stats.total) * 100)}% healthy + {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% healthy
-
- {stats.healthy > 0 && ( -
- )} - {stats.warning > 0 && ( -
- )} - {stats.critical > 0 && ( -
- )} - {stats.total - stats.healthy - stats.warning - stats.critical > 0 && ( -
- )} -
-
- - - Healthy ({stats.healthy}) - - {stats.warning > 0 && ( - - - Warning ({stats.warning}) - - )} - {stats.critical > 0 && ( - - - Critical ({stats.critical}) - - )} -
+ {stats.total > 0 ? ( + <> +
+ {stats.healthy > 0 && ( +
+ )} + {stats.warning > 0 && ( +
+ )} + {stats.critical > 0 && ( +
+ )} + {stats.total - stats.healthy - stats.warning - stats.critical > 0 && ( +
+ )} +
+
+ + + Healthy ({stats.healthy}) + + {stats.warning > 0 && ( + + + Warning ({stats.warning}) + + )} + {stats.critical > 0 && ( + + + Critical ({stats.critical}) + + )} +
+ + ) : ( +
No services registered
+ )}
- )} - {/* Main Content Grid */} -
{/* Services with Issues */} -
+

Services with Issues

@@ -227,40 +246,48 @@ function Dashboard() {
- {/* Polling Issues */} - {servicesWithPollingIssues.length > 0 && ( -
-
-

Polling Issues

- - {servicesWithPollingIssues.length} - -
-
-
    - {servicesWithPollingIssues.map(svc => ( -
  • - - -
    -
    {svc.name}
    -
    {svc.teamName}
    + {/* Recent Activity */} +
    +
    +

    Recent Activity

    +
    +
    + {recentActivity.length > 0 ? ( +
      + {recentActivity.map(event => { + const status = event.current_healthy ? 'healthy' : 'critical'; + const previousLabel = event.previous_healthy === null + ? 'new' + : event.previous_healthy ? 'healthy' : 'critical'; + const currentLabel = event.current_healthy ? 'healthy' : 'critical'; + return ( +
    • +
      +
      +
      + + {event.service_name} + + {' '}{event.dependency_name}: {previousLabel} → {currentLabel} +
      +
      + {formatRelativeTime(event.recorded_at)} +
      - -
      - {svc.pollError - ? 'Poll failed' - : `${svc.warningCount} warning${svc.warningCount !== 1 ? 's' : ''}`} -
      -
    • - ))} + + ); + })}
    -
    + ) : ( +
    + No recent status changes +
    + )}
    - )} +
    {/* Team Health Summary */} -
    +

    Health by Team

    @@ -277,19 +304,19 @@ function Dashboard() {
    {healthy > 0 && ( - + {healthy} )} {warning > 0 && ( - + {warning} )} {critical > 0 && ( - + {critical} @@ -306,60 +333,8 @@ function Dashboard() {
    - {/* Recent Activity */} -
    -
    -

    Recent Activity

    -
    -
    - {recentActivity.length > 0 ? ( -
      - {recentActivity.map(event => { - const status = event.current_healthy ? 'healthy' : 'critical'; - const previousLabel = event.previous_healthy === null - ? 'new' - : event.previous_healthy ? 'healthy' : 'critical'; - const currentLabel = event.current_healthy ? 'healthy' : 'critical'; - return ( -
    • - {/* eslint-disable-next-line security/detect-object-injection */} -
      - {event.current_healthy ? ( - - - - ) : ( - - - - - )} -
      -
      -
      - - {event.service_name} - - {' '}{event.dependency_name}: {previousLabel} → {currentLabel} -
      -
      - {formatRelativeTime(event.recorded_at)} -
      -
      -
    • - ); - })} -
    - ) : ( -
    - No recent status changes -
    - )} -
    -
    - {/* Most Unstable Dependencies */} -
    +

    Most Unstable (24h)

    @@ -369,10 +344,11 @@ function Dashboard() { {unstableDependencies.map(dep => { const maxCount = unstableDependencies[0].change_count; const barWidth = maxCount > 0 ? (dep.change_count / maxCount) * 100 : 0; + const healthClass = dep.current_healthy ? styles.healthy : styles.critical; return (
  • - +
    {dep.dependency_name} @@ -381,10 +357,12 @@ function Dashboard() {
    -
    +
    +
    +
    {dep.change_count}
  • @@ -393,10 +371,45 @@ function Dashboard() {
) : (
- - - -
All dependencies stable
+ All dependencies stable +
+ )} +
+
+ + {/* Polling Issues — always rendered to prevent layout shift */} +
+
+

Polling Issues

+ {servicesWithPollingIssues.length > 0 && ( + + {servicesWithPollingIssues.length} + + )} +
+
+ {servicesWithPollingIssues.length > 0 ? ( +
    + {servicesWithPollingIssues.map(svc => ( +
  • + + +
    +
    {svc.name}
    +
    {svc.teamName}
    +
    + +
    + {svc.pollError + ? 'Poll failed' + : `${svc.warningCount} warning${svc.warningCount !== 1 ? 's' : ''}`} +
    +
  • + ))} +
+ ) : ( +
+ No polling issues
)}
From ea945a39e879cce58520b001ce0ae886e28df570 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 20:39:03 -0700 Subject: [PATCH 05/21] DPS-70: Add reusable Tabs component with URL-persisted state --- client/src/components/common/Tabs.module.css | 46 ++++++ client/src/components/common/Tabs.test.tsx | 153 +++++++++++++++++++ client/src/components/common/Tabs.tsx | 128 ++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 client/src/components/common/Tabs.module.css create mode 100644 client/src/components/common/Tabs.test.tsx create mode 100644 client/src/components/common/Tabs.tsx diff --git a/client/src/components/common/Tabs.module.css b/client/src/components/common/Tabs.module.css new file mode 100644 index 0000000..b50d032 --- /dev/null +++ b/client/src/components/common/Tabs.module.css @@ -0,0 +1,46 @@ +/* ============================================================= + Tabs — Minimal tab bar with crisp active indicator + ============================================================= */ + +.tabList { + display: flex; + border-bottom: 1px solid var(--color-border); + position: relative; + gap: 0; +} + +.tab { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-muted); + padding: var(--space-2) var(--space-4); + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + cursor: pointer; + transition: color var(--duration-fast) ease, + border-color var(--duration-fast) ease; + white-space: nowrap; +} + +.tab:hover { + color: var(--color-text-secondary); +} + +.tab:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} + +.tabActive { + composes: tab; + color: var(--color-text); + font-weight: var(--font-semibold); + border-bottom-color: var(--color-accent); +} + +.tabPanel { + padding-top: var(--space-5); +} diff --git a/client/src/components/common/Tabs.test.tsx b/client/src/components/common/Tabs.test.tsx new file mode 100644 index 0000000..fecac9e --- /dev/null +++ b/client/src/components/common/Tabs.test.tsx @@ -0,0 +1,153 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { Tabs, TabList, Tab, TabPanel } from './Tabs'; + +function renderTabs( + initialEntries = ['/'], + props: { defaultTab?: string; storageKey?: string; urlParam?: string } = {} +) { + const { defaultTab = 'one', storageKey, urlParam } = props; + return render( + + + + Tab One + Tab Two + Tab Three + + Content One + Content Two + Content Three + + + ); +} + +beforeEach(() => { + localStorage.clear(); +}); + +describe('Tabs', () => { + it('renders all tab buttons', () => { + renderTabs(); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Tab Three' })).toBeInTheDocument(); + }); + + it('shows default tab content', () => { + renderTabs(); + + expect(screen.getByText('Content One')).toBeInTheDocument(); + expect(screen.queryByText('Content Two')).not.toBeInTheDocument(); + }); + + it('switches tab on click', () => { + renderTabs(); + + fireEvent.click(screen.getByRole('tab', { name: 'Tab Two' })); + + expect(screen.queryByText('Content One')).not.toBeInTheDocument(); + expect(screen.getByText('Content Two')).toBeInTheDocument(); + }); + + it('sets aria-selected on active tab', () => { + renderTabs(); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + + fireEvent.click(screen.getByRole('tab', { name: 'Tab Two' })); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute( + 'aria-selected', + 'false' + ); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('renders tabpanel with correct ARIA attributes', () => { + renderTabs(); + + const panel = screen.getByRole('tabpanel'); + expect(panel).toHaveAttribute('id', 'tabpanel-one'); + expect(panel).toHaveAttribute('aria-labelledby', 'tab-one'); + }); + + it('renders tablist with aria-label', () => { + renderTabs(); + + expect(screen.getByRole('tablist')).toHaveAttribute( + 'aria-label', + 'Test tabs' + ); + }); + + it('reads initial tab from URL search params', () => { + renderTabs(['/?tab=two']); + + expect(screen.queryByText('Content One')).not.toBeInTheDocument(); + expect(screen.getByText('Content Two')).toBeInTheDocument(); + }); + + it('reads initial tab from custom URL param', () => { + renderTabs(['/?section=three'], { defaultTab: 'one', urlParam: 'section' }); + + expect(screen.getByText('Content Three')).toBeInTheDocument(); + }); + + it('persists tab to localStorage when storageKey is provided', () => { + renderTabs(undefined, { storageKey: 'test-tab' }); + + fireEvent.click(screen.getByRole('tab', { name: 'Tab Two' })); + + expect(localStorage.getItem('test-tab')).toBe('two'); + }); + + it('reads initial tab from localStorage when URL has no param', () => { + localStorage.setItem('test-tab', 'three'); + renderTabs(['/'], { storageKey: 'test-tab' }); + + expect(screen.getByText('Content Three')).toBeInTheDocument(); + }); + + it('URL param takes precedence over localStorage', () => { + localStorage.setItem('test-tab', 'three'); + renderTabs(['/?tab=two'], { storageKey: 'test-tab' }); + + expect(screen.getByText('Content Two')).toBeInTheDocument(); + }); + + it('applies active CSS class to active tab', () => { + renderTabs(); + + const activeTab = screen.getByRole('tab', { name: 'Tab One' }); + const inactiveTab = screen.getByRole('tab', { name: 'Tab Two' }); + + expect(activeTab.className).toContain('tabActive'); + expect(inactiveTab.className).not.toContain('tabActive'); + }); + + it('sets tabIndex 0 on active tab and -1 on inactive', () => { + renderTabs(); + + expect(screen.getByRole('tab', { name: 'Tab One' })).toHaveAttribute( + 'tabindex', + '0' + ); + expect(screen.getByRole('tab', { name: 'Tab Two' })).toHaveAttribute( + 'tabindex', + '-1' + ); + }); +}); diff --git a/client/src/components/common/Tabs.tsx b/client/src/components/common/Tabs.tsx new file mode 100644 index 0000000..5e0e39c --- /dev/null +++ b/client/src/components/common/Tabs.tsx @@ -0,0 +1,128 @@ +import { + createContext, + useContext, + useCallback, + useMemo, + type ReactNode, +} from 'react'; +import { useSearchParams } from 'react-router-dom'; +import styles from './Tabs.module.css'; + +interface TabsContextType { + activeTab: string; + setActiveTab: (value: string) => void; +} + +const TabsContext = createContext(null); + +function useTabsContext() { + const ctx = useContext(TabsContext); + if (!ctx) throw new Error('Tab components must be used within '); + return ctx; +} + +interface TabsProps { + defaultTab: string; + urlParam?: string; + storageKey?: string; + children: ReactNode; +} + +function Tabs({ defaultTab, urlParam = 'tab', storageKey, children }: TabsProps) { + const [searchParams, setSearchParams] = useSearchParams(); + + const activeTab = useMemo(() => { + const fromUrl = searchParams.get(urlParam); + if (fromUrl) return fromUrl; + if (storageKey) { + const stored = localStorage.getItem(storageKey); + if (stored) return stored; + } + return defaultTab; + }, [searchParams, urlParam, storageKey, defaultTab]); + + const setActiveTab = useCallback( + (value: string) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + next.set(urlParam, value); + return next; + }, + { replace: true } + ); + if (storageKey) { + localStorage.setItem(storageKey, value); + } + }, + [setSearchParams, urlParam, storageKey] + ); + + const ctx = useMemo( + () => ({ activeTab, setActiveTab }), + [activeTab, setActiveTab] + ); + + return {children}; +} + +interface TabListProps { + children: ReactNode; + 'aria-label'?: string; +} + +function TabList({ children, 'aria-label': ariaLabel }: TabListProps) { + return ( +
+ {children} +
+ ); +} + +interface TabProps { + value: string; + children: ReactNode; +} + +function Tab({ value, children }: TabProps) { + const { activeTab, setActiveTab } = useTabsContext(); + const isActive = activeTab === value; + + return ( + + ); +} + +interface TabPanelProps { + value: string; + children: ReactNode; +} + +function TabPanel({ value, children }: TabPanelProps) { + const { activeTab } = useTabsContext(); + if (activeTab !== value) return null; + + return ( +
+ {children} +
+ ); +} + +export { Tabs, TabList, Tab, TabPanel }; From 5a0ceb4e3b4f0abd1526bae217f6bfc1db5f6033 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 20:48:36 -0700 Subject: [PATCH 06/21] DPS-70: Refactor TeamDetail into tabbed layout with tests Split TeamDetail into 5 tabs: Overview, Members, Manifests, Services, and Alerts Config. Uses reusable Tabs component with URL-persisted state and localStorage fallback. Replaced inline SVGs with Lucide icons, applied design tokens throughout. Updated tests to handle tabbed navigation. --- .../pages/Teams/TeamDetail.test.tsx | 722 ++++++++++-------- .../src/components/pages/Teams/TeamDetail.tsx | 436 +++++------ .../components/pages/Teams/Teams.module.css | 110 +-- 3 files changed, 674 insertions(+), 594 deletions(-) diff --git a/client/src/components/pages/Teams/TeamDetail.test.tsx b/client/src/components/pages/Teams/TeamDetail.test.tsx index f3930b6..6ba8d74 100644 --- a/client/src/components/pages/Teams/TeamDetail.test.tsx +++ b/client/src/components/pages/Teams/TeamDetail.test.tsx @@ -24,7 +24,7 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); -// Mock AlertChannels, AlertRules, and AlertHistory to isolate TeamDetail tests +// Mock AlertChannels, AlertRules, AlertHistory, AlertMutes, ManifestStatusCard jest.mock('./AlertChannels', () => { const AlertChannels = () =>
; AlertChannels.displayName = 'AlertChannels'; @@ -80,10 +80,11 @@ const mockTeam = { updated_at: '2024-01-01', }; -function renderTeamDetail(id = 't1', isAdmin = false) { +function renderTeamDetail(id = 't1', isAdmin = false, initialTab?: string) { mockUseAuth.mockReturnValue({ isAdmin }); + const path = initialTab ? `/teams/${id}?tab=${initialTab}` : `/teams/${id}`; return render( - + } /> Teams List
} /> @@ -96,6 +97,7 @@ beforeEach(() => { mockFetch.mockReset(); mockUseAuth.mockReset(); mockNavigate.mockReset(); + localStorage.clear(); }); describe('TeamDetail', () => { @@ -119,8 +121,6 @@ describe('TeamDetail', () => { }); expect(screen.getByText('Test description')).toBeInTheDocument(); - expect(screen.getByText('User One')).toBeInTheDocument(); - expect(screen.getByText('User Two')).toBeInTheDocument(); }); it('displays error state and allows retry', async () => { @@ -154,65 +154,6 @@ describe('TeamDetail', () => { expect(screen.getByText('Back to Teams')).toBeInTheDocument(); }); - it('displays members with roles', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse(mockUsers)); - - renderTeamDetail(); - - await waitFor(() => { - expect(screen.getByText('Members')).toBeInTheDocument(); - }); - - expect(screen.getByText('User One')).toBeInTheDocument(); - expect(screen.getByText('user1@example.com')).toBeInTheDocument(); - expect(screen.getByText('Lead')).toBeInTheDocument(); - expect(screen.getByText('Member')).toBeInTheDocument(); - }); - - it('displays services list', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse(mockUsers)); - - renderTeamDetail(); - - await waitFor(() => { - expect(screen.getByText('Services')).toBeInTheDocument(); - }); - - expect(screen.getByText('Service A')).toBeInTheDocument(); - expect(screen.getByText('Service B')).toBeInTheDocument(); - expect(screen.getByText('Inactive')).toBeInTheDocument(); - }); - - it('shows empty state for no members', async () => { - const teamNoMembers = { ...mockTeam, members: [] }; - mockFetch - .mockResolvedValueOnce(jsonResponse(teamNoMembers)) - .mockResolvedValueOnce(jsonResponse(mockUsers)); - - renderTeamDetail(); - - await waitFor(() => { - expect(screen.getByText('No members in this team yet.')).toBeInTheDocument(); - }); - }); - - it('shows empty state for no services', async () => { - const teamNoServices = { ...mockTeam, services: [] }; - mockFetch - .mockResolvedValueOnce(jsonResponse(teamNoServices)) - .mockResolvedValueOnce(jsonResponse(mockUsers)); - - renderTeamDetail(); - - await waitFor(() => { - expect(screen.getByText('No services assigned to this team yet.')).toBeInTheDocument(); - }); - }); - it('shows admin actions for admin users', async () => { mockFetch .mockResolvedValueOnce(jsonResponse(mockTeam)) @@ -274,382 +215,454 @@ describe('TeamDetail', () => { expect(screen.getByText(/Are you sure you want to delete/)).toBeInTheDocument(); }); - it('displays add member form for admin with available users', async () => { - const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; + it('displays back link to teams list', async () => { mockFetch .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse(availableUsers)); + .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail('t1', true); + renderTeamDetail(); await waitFor(() => { - expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('Back to Teams')).toBeInTheDocument(); }); - - expect(screen.getByText('Add Member')).toBeInTheDocument(); - expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); }); - it('hides add member form when no available users', async () => { + it('shows team without description', async () => { + const teamNoDesc = { ...mockTeam, description: null }; mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(teamNoDesc)) .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail('t1', true); + renderTeamDetail(); await waitFor(() => { expect(screen.getByText('Test Team')).toBeInTheDocument(); }); - expect(screen.queryByText('Add Member')).not.toBeInTheDocument(); + expect(screen.queryByText('Test description')).not.toBeInTheDocument(); }); - it('adds member to team', async () => { - const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse(availableUsers)) - .mockResolvedValueOnce(jsonResponse([])) // alert channels - .mockResolvedValueOnce(jsonResponse({ success: true })) - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + describe('tabs', () => { + it('renders all tab buttons', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail('t1', true); + renderTeamDetail(); - await waitFor(() => { - expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument(); + }); - // Select user - fireEvent.change(screen.getByDisplayValue('Select a user...'), { - target: { value: 'u3' }, + expect(screen.getByRole('tab', { name: /Members/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Manifests/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Services/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Alerts Config/ })).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('Add Member')); + it('defaults to overview tab', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail(); - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/teams/t1/members', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ user_id: 'u3', role: 'member' }), - }) - ); + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Overview/ })).toHaveAttribute('aria-selected', 'true'); + }); }); - }); - it('adds member as lead', async () => { - const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse(availableUsers)) - .mockResolvedValueOnce(jsonResponse([])) // alert channels - .mockResolvedValueOnce(jsonResponse({ success: true })) - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + it('shows overview content by default and hides other tab content', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail('t1', true); + renderTeamDetail(); - await waitFor(() => { - expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); - }); + await waitFor(() => { + expect(screen.getByText('Test Team')).toBeInTheDocument(); + }); - // Select user - fireEvent.change(screen.getByDisplayValue('Select a user...'), { - target: { value: 'u3' }, + // Members content should not be visible + expect(screen.queryByText('user1@example.com')).not.toBeInTheDocument(); }); - // Select lead role - fireEvent.change(screen.getByDisplayValue('Member'), { - target: { value: 'lead' }, + it('switches tab content when clicking a tab', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Team')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('tab', { name: /Members/ })); + + expect(screen.getByText('User One')).toBeInTheDocument(); + expect(screen.getByText('user1@example.com')).toBeInTheDocument(); }); - fireEvent.click(screen.getByText('Add Member')); + it('respects URL param for initial tab', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/teams/t1/members', - expect.objectContaining({ - body: JSON.stringify({ user_id: 'u3', role: 'lead' }), - }) - ); + renderTeamDetail('t1', false, 'services'); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Services/ })).toHaveAttribute('aria-selected', 'true'); + }); + + expect(screen.getByText('Service A')).toBeInTheDocument(); }); - }); - it('promotes member to lead', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])) - .mockResolvedValueOnce(jsonResponse([])) // alert channels - .mockResolvedValueOnce(jsonResponse({ success: true })) - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + it('shows member count in tab label', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail('t1', true); + renderTeamDetail(); - await waitFor(() => { - expect(screen.getByText('User Two')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Members \(2\)/ })).toBeInTheDocument(); + }); }); - const promoteButton = screen.getByText('Promote'); - fireEvent.click(promoteButton); + it('shows service count in tab label', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/teams/t1/members/u2', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ role: 'lead' }), - }) - ); + renderTeamDetail(); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Services \(2\)/ })).toBeInTheDocument(); + }); }); }); - it('demotes lead to member', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])) - .mockResolvedValueOnce(jsonResponse([])) // alert channels - .mockResolvedValueOnce(jsonResponse({ success: true })) - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + describe('members tab', () => { + it('displays members with roles', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(mockUsers)); - renderTeamDetail('t1', true); + renderTeamDetail('t1', false, 'members'); - await waitFor(() => { - expect(screen.getByText('User One')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument(); + }); + + expect(screen.getByText('user1@example.com')).toBeInTheDocument(); + expect(screen.getByText('Lead')).toBeInTheDocument(); + expect(screen.getByText('Member')).toBeInTheDocument(); }); - const demoteButton = screen.getByText('Demote'); - fireEvent.click(demoteButton); + it('shows empty state for no members', async () => { + const teamNoMembers = { ...mockTeam, members: [] }; + mockFetch + .mockResolvedValueOnce(jsonResponse(teamNoMembers)) + .mockResolvedValueOnce(jsonResponse(mockUsers)); - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/teams/t1/members/u1', - expect.objectContaining({ - method: 'PUT', - body: JSON.stringify({ role: 'member' }), - }) - ); + renderTeamDetail('t1', false, 'members'); + + await waitFor(() => { + expect(screen.getByText('No members in this team yet.')).toBeInTheDocument(); + }); }); - }); - it('removes member from team', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])) - .mockResolvedValueOnce(jsonResponse([])) // alert channels - .mockResolvedValueOnce(jsonResponse({ success: true })) - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + it('displays add member form for admin with available users', async () => { + const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(availableUsers)); - renderTeamDetail('t1', true); + renderTeamDetail('t1', true, 'members'); - await waitFor(() => { - expect(screen.getByText('User Two')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('User')).toBeInTheDocument(); + }); + + expect(screen.getByText('Add Member')).toBeInTheDocument(); + expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); }); - const removeButtons = screen.getAllByText('Remove'); - fireEvent.click(removeButtons[0]); + it('hides add member form when no available users', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail('t1', true, 'members'); - await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - '/api/teams/t1/members/u1', - expect.objectContaining({ method: 'DELETE' }) - ); + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Add Member')).not.toBeInTheDocument(); }); - }); - it('displays member count correctly', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + it('adds member to team', async () => { + const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(availableUsers)) + .mockResolvedValueOnce(jsonResponse([])) // alert channels + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail(); + renderTeamDetail('t1', true, 'members'); - await waitFor(() => { - expect(screen.getByText('2 members')).toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); + }); - it('displays singular member count', async () => { - const teamOneMember = { ...mockTeam, members: [mockTeam.members[0]] }; - mockFetch - .mockResolvedValueOnce(jsonResponse(teamOneMember)) - .mockResolvedValueOnce(jsonResponse([])); + fireEvent.change(screen.getByDisplayValue('Select a user...'), { + target: { value: 'u3' }, + }); - renderTeamDetail(); + fireEvent.click(screen.getByText('Add Member')); - await waitFor(() => { - expect(screen.getByText('1 member')).toBeInTheDocument(); + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/teams/t1/members', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ user_id: 'u3', role: 'member' }), + }) + ); + }); }); - }); - it('displays service count correctly', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + it('adds member as lead', async () => { + const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(availableUsers)) + .mockResolvedValueOnce(jsonResponse([])) // alert channels + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail(); + renderTeamDetail('t1', true, 'members'); - await waitFor(() => { - expect(screen.getByText('2 services')).toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); + }); - it('displays singular service count', async () => { - const teamOneService = { ...mockTeam, services: [mockTeam.services[0]] }; - mockFetch - .mockResolvedValueOnce(jsonResponse(teamOneService)) - .mockResolvedValueOnce(jsonResponse([])); + fireEvent.change(screen.getByDisplayValue('Select a user...'), { + target: { value: 'u3' }, + }); - renderTeamDetail(); + fireEvent.change(screen.getByDisplayValue('Member'), { + target: { value: 'lead' }, + }); - await waitFor(() => { - expect(screen.getByText('1 service')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Add Member')); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/teams/t1/members', + expect.objectContaining({ + body: JSON.stringify({ user_id: 'u3', role: 'lead' }), + }) + ); + }); }); - }); - it('hides member actions for non-admin users', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + it('promotes member to lead', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse([])) // alert channels + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail('t1', false); + renderTeamDetail('t1', true, 'members'); - await waitFor(() => { - expect(screen.getByText('User One')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('User Two')).toBeInTheDocument(); + }); + + const promoteButton = screen.getByText('Promote'); + fireEvent.click(promoteButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/teams/t1/members/u2', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ role: 'lead' }), + }) + ); + }); }); - expect(screen.queryByText('Promote')).not.toBeInTheDocument(); - expect(screen.queryByText('Demote')).not.toBeInTheDocument(); - expect(screen.queryByText('Remove')).not.toBeInTheDocument(); - }); + it('demotes lead to member', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse([])) // alert channels + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - it('shows team without description', async () => { - const teamNoDesc = { ...mockTeam, description: null }; - mockFetch - .mockResolvedValueOnce(jsonResponse(teamNoDesc)) - .mockResolvedValueOnce(jsonResponse([])); + renderTeamDetail('t1', true, 'members'); - renderTeamDetail(); + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.getByText('Test Team')).toBeInTheDocument(); + const demoteButton = screen.getByText('Demote'); + fireEvent.click(demoteButton); + + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/teams/t1/members/u1', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ role: 'member' }), + }) + ); + }); }); - expect(screen.queryByText('Test description')).not.toBeInTheDocument(); - }); + it('removes member from team', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse([])) // alert channels + .mockResolvedValueOnce(jsonResponse({ success: true })) + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); - it('displays add member error', async () => { - const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse(availableUsers)) - .mockResolvedValueOnce(jsonResponse([])) // alert channels - .mockRejectedValueOnce(new Error('Failed to add member')); + renderTeamDetail('t1', true, 'members'); - renderTeamDetail('t1', true); + await waitFor(() => { + expect(screen.getByText('User Two')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); - }); + const removeButtons = screen.getAllByText('Remove'); + fireEvent.click(removeButtons[0]); - fireEvent.change(screen.getByDisplayValue('Select a user...'), { - target: { value: 'u3' }, + await waitFor(() => { + expect(mockFetch).toHaveBeenCalledWith( + '/api/teams/t1/members/u1', + expect.objectContaining({ method: 'DELETE' }) + ); + }); }); - fireEvent.click(screen.getByText('Add Member')); + it('hides member actions for non-admin users', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail('t1', false, 'members'); - await waitFor(() => { - expect(screen.getByText('Failed to add member')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Promote')).not.toBeInTheDocument(); + expect(screen.queryByText('Demote')).not.toBeInTheDocument(); + expect(screen.queryByText('Remove')).not.toBeInTheDocument(); }); - }); - it('displays back link to teams list', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])); + it('displays add member error', async () => { + const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(availableUsers)) + .mockResolvedValueOnce(jsonResponse([])) // alert channels + .mockRejectedValueOnce(new Error('Failed to add member')); - renderTeamDetail(); + renderTeamDetail('t1', true, 'members'); - await waitFor(() => { - expect(screen.getByText('Back to Teams')).toBeInTheDocument(); - }); - }); + await waitFor(() => { + expect(screen.getByText('User Three (user3@example.com)')).toBeInTheDocument(); + }); - it('disables add member button when no user selected', async () => { - const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse(availableUsers)); + fireEvent.change(screen.getByDisplayValue('Select a user...'), { + target: { value: 'u3' }, + }); - renderTeamDetail('t1', true); + fireEvent.click(screen.getByText('Add Member')); - await waitFor(() => { - expect(screen.getByText('Add Member')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Failed to add member')).toBeInTheDocument(); + }); }); - expect(screen.getByText('Add Member')).toBeDisabled(); - }); + it('disables add member button when no user selected', async () => { + const availableUsers = [{ id: 'u3', name: 'User Three', email: 'user3@example.com' }]; + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(availableUsers)); - it('shows error inline after team is loaded', async () => { - mockFetch - .mockResolvedValueOnce(jsonResponse(mockTeam)) - .mockResolvedValueOnce(jsonResponse([])) - .mockResolvedValueOnce(jsonResponse([])) // alert channels - .mockRejectedValueOnce(new Error('Action failed')); + renderTeamDetail('t1', true, 'members'); - renderTeamDetail('t1', true); + await waitFor(() => { + expect(screen.getByText('Add Member')).toBeInTheDocument(); + }); - await waitFor(() => { - expect(screen.getByText('User One')).toBeInTheDocument(); + expect(screen.getByText('Add Member')).toBeDisabled(); }); - const removeButtons = screen.getAllByText('Remove'); - fireEvent.click(removeButtons[0]); + it('shows error inline after team is loaded', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])) + .mockResolvedValueOnce(jsonResponse([])) // alert channels + .mockRejectedValueOnce(new Error('Action failed')); + + renderTeamDetail('t1', true, 'members'); - await waitFor(() => { - expect(screen.getByText('Action failed')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('User One')).toBeInTheDocument(); + }); + + const removeButtons = screen.getAllByText('Remove'); + fireEvent.click(removeButtons[0]); + + await waitFor(() => { + expect(screen.getByText('Action failed')).toBeInTheDocument(); + }); }); }); - describe('team key badge', () => { - it('displays key badge when team has a key', async () => { - const teamWithKey = { ...mockTeam, key: 'platform-team' }; + describe('services tab', () => { + it('displays services list', async () => { mockFetch - .mockResolvedValueOnce(jsonResponse(teamWithKey)) - .mockResolvedValueOnce(jsonResponse([])); + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse(mockUsers)); - renderTeamDetail(); + renderTeamDetail('t1', false, 'services'); await waitFor(() => { - expect(screen.getByText('platform-team')).toBeInTheDocument(); + expect(screen.getByText('Service A')).toBeInTheDocument(); }); - expect(screen.getByText('platform-team').tagName).toBe('CODE'); + expect(screen.getByText('Service B')).toBeInTheDocument(); + expect(screen.getByText('Inactive')).toBeInTheDocument(); }); - it('does not render key badge when team key is null', async () => { - const teamNoKey = { ...mockTeam, key: null }; + it('shows empty state for no services', async () => { + const teamNoServices = { ...mockTeam, services: [] }; mockFetch - .mockResolvedValueOnce(jsonResponse(teamNoKey)) - .mockResolvedValueOnce(jsonResponse([])); + .mockResolvedValueOnce(jsonResponse(teamNoServices)) + .mockResolvedValueOnce(jsonResponse(mockUsers)); - renderTeamDetail(); + renderTeamDetail('t1', false, 'services'); await waitFor(() => { - expect(screen.getByText('Test Team')).toBeInTheDocument(); - }); - - const codeElements = document.querySelectorAll('code'); - codeElements.forEach((el) => { - expect(el.textContent).not.toBe(''); + expect(screen.getByText('No services assigned to this team yet.')).toBeInTheDocument(); }); }); - }); - describe('manifest badges', () => { it('shows [M] badge for manifest-managed services', async () => { const teamWithManifest = { ...mockTeam, @@ -663,7 +676,7 @@ describe('TeamDetail', () => { .mockResolvedValueOnce(jsonResponse(teamWithManifest)) .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail(); + renderTeamDetail('t1', false, 'services'); await waitFor(() => { expect(screen.getByText('Manifest Service')).toBeInTheDocument(); @@ -679,7 +692,7 @@ describe('TeamDetail', () => { .mockResolvedValueOnce(jsonResponse(mockTeam)) .mockResolvedValueOnce(jsonResponse([])); - renderTeamDetail(); + renderTeamDetail('t1', false, 'services'); await waitFor(() => { expect(screen.getByText('Service A')).toBeInTheDocument(); @@ -688,4 +701,71 @@ describe('TeamDetail', () => { expect(screen.queryByTitle('Managed by manifest')).not.toBeInTheDocument(); }); }); + + describe('manifests tab', () => { + it('renders ManifestStatusCard', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail('t1', false, 'manifests'); + + await waitFor(() => { + expect(screen.getByTestId('manifest-status-card')).toBeInTheDocument(); + }); + }); + }); + + describe('alerts tab', () => { + it('renders all alert sub-sections', async () => { + mockFetch + .mockResolvedValueOnce(jsonResponse(mockTeam)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail('t1', false, 'alerts'); + + await waitFor(() => { + expect(screen.getByTestId('alert-channels')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('alert-rules')).toBeInTheDocument(); + expect(screen.getByTestId('alert-mutes')).toBeInTheDocument(); + expect(screen.getByTestId('alert-history')).toBeInTheDocument(); + }); + }); + + describe('team key badge', () => { + it('displays key badge when team has a key', async () => { + const teamWithKey = { ...mockTeam, key: 'platform-team' }; + mockFetch + .mockResolvedValueOnce(jsonResponse(teamWithKey)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail(); + + await waitFor(() => { + expect(screen.getByText('platform-team')).toBeInTheDocument(); + }); + + expect(screen.getByText('platform-team').tagName).toBe('CODE'); + }); + + it('does not render key badge when team key is null', async () => { + const teamNoKey = { ...mockTeam, key: null }; + mockFetch + .mockResolvedValueOnce(jsonResponse(teamNoKey)) + .mockResolvedValueOnce(jsonResponse([])); + + renderTeamDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Team')).toBeInTheDocument(); + }); + + const codeElements = document.querySelectorAll('code'); + codeElements.forEach((el) => { + expect(el.textContent).not.toBe(''); + }); + }); + }); }); diff --git a/client/src/components/pages/Teams/TeamDetail.tsx b/client/src/components/pages/Teams/TeamDetail.tsx index 5d2b9d0..1904de2 100644 --- a/client/src/components/pages/Teams/TeamDetail.tsx +++ b/client/src/components/pages/Teams/TeamDetail.tsx @@ -1,10 +1,12 @@ import { useState, useEffect, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; +import { ChevronLeft, Pencil, Trash2 } from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { useTeamDetail, useTeamMembers } from '../../../hooks/useTeamDetail'; import { parseContact } from '../../../utils/dependency'; import Modal from '../../common/Modal'; import ConfirmDialog from '../../common/ConfirmDialog'; +import { Tabs, TabList, Tab, TabPanel } from '../../common/Tabs'; import TeamForm from './TeamForm'; import AlertChannels from './AlertChannels'; import AlertRules from './AlertRules'; @@ -115,16 +117,7 @@ function TeamDetail() { return (
- - - + Back to Teams @@ -134,238 +127,223 @@ function TeamDetail() {
)} -
-
-

{team.name}

- {team.key && ( - {team.key} - )} - {team.description && ( -

{team.description}

- )} - {team.contact && (() => { - const contactData = parseContact(team.contact); - if (!contactData) return null; - return ( -
- {Object.entries(contactData).map(([label, value]) => ( - - {label}:{' '} - {String(value)} - - ))} + + + Overview + + Members ({team.members.length}) + + Manifests + + Services ({team.services.length}) + + Alerts Config + + + {/* Overview Tab */} + +
+
+

{team.name}

+ {team.key && ( + {team.key} + )} + {team.description && ( +

{team.description}

+ )} + {team.contact && (() => { + const contactData = parseContact(team.contact); + if (!contactData) return null; + return ( +
+ {Object.entries(contactData).map(([label, value]) => ( + + {label}:{' '} + {String(value)} + + ))} +
+ ); + })()} +
+ {isAdmin && ( +
+ +
- ); - })()} -
- {isAdmin && ( -
- - + )}
- )} -
- - {/* Members Section */} -
-
-

Members

- - {team.members.length} {team.members.length === 1 ? 'member' : 'members'} - -
+ - {isAdmin && availableUsers.length > 0 && ( -
-
- - -
-
- - setSelectedUserId(e.target.value)} + className={styles.select} + disabled={isAddingMember} + > + + {availableUsers.map((u) => ( + + ))} + +
+
+ + +
+
- -
- )} + )} - {addMemberError && ( -
- {addMemberError} -
- )} + {addMemberError && ( +
+ {addMemberError} +
+ )} - {team.members.length === 0 ? ( -
-

No members in this team yet.

-
- ) : ( -
- - - - - - - {isAdmin && } - - - - {team.members.map((member) => ( - - - - - {isAdmin && ( + {team.members.length === 0 ? ( +
+

No members in this team yet.

+
+ ) : ( +
+
NameEmailRoleActions
{member.user.name}{member.user.email} - - {member.role === 'lead' ? 'Lead' : 'Member'} - -
+ + + + + + {isAdmin && } + + + + {team.members.map((member) => ( + + + - )} - - ))} - -
NameEmailRoleActions
{member.user.name}{member.user.email} -
- - -
+ + {member.role === 'lead' ? 'Lead' : 'Member'} +
-
- )} -
- - {/* Manifest Sync Section */} - + {isAdmin && ( + +
+ + +
+ + )} + + ))} + + +
+ )} + - {/* Services Section */} -
-
-

Services

- - {team.services.length} {team.services.length === 1 ? 'service' : 'services'} - -
+ {/* Manifests Tab */} + + + - {team.services.length === 0 ? ( -
-

No services assigned to this team yet.

-
- ) : ( -
- {team.services.map((service) => ( -
-
- - {service.name} - - {service.manifest_managed === 1 && ( - M - )} - {!service.is_active && ( - - Inactive - - )} + {/* Services Tab */} + + {team.services.length === 0 ? ( +
+

No services assigned to this team yet.

+
+ ) : ( +
+ {team.services.map((service) => ( +
+
+ + {service.name} + + {service.manifest_managed === 1 && ( + M + )} + {!service.is_active && ( + + Inactive + + )} +
-
- ))} -
- )} -
- - {/* Alert Channels & Rules (side-by-side on wider screens) */} -
- - -
- - {/* Alert Mutes Section */} - + ))} +
+ )} + - {/* Alert History Section */} - + {/* Alerts Config Tab */} + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ Date: Thu, 5 Mar 2026 21:07:23 -0700 Subject: [PATCH 07/21] DPS-71: Refactor ServiceDetail into tabbed layout with tests Split ServiceDetail into four tabs (Overview, Dependencies, Dependent Reports, Poll Issues) using the reusable Tabs component. Replace inline SVGs with Lucide icons. Add empty state for external/inactive services on Poll Issues tab. Update tests to navigate tabs before asserting tab-specific content. --- .../pages/Services/ServiceDetail.test.tsx | 249 ++++++++--- .../pages/Services/ServiceDetail.tsx | 419 +++++++++--------- 2 files changed, 392 insertions(+), 276 deletions(-) diff --git a/client/src/components/pages/Services/ServiceDetail.test.tsx b/client/src/components/pages/Services/ServiceDetail.test.tsx index 8f9ad65..401df06 100644 --- a/client/src/components/pages/Services/ServiceDetail.test.tsx +++ b/client/src/components/pages/Services/ServiceDetail.test.tsx @@ -118,10 +118,6 @@ const mockService = { ], }; -/** - * Build a default mock implementation that handles the initial service + teams load, - * plus the additional DependencyList API calls (aliases, canonical names, associations, suggestions). - */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function setupDefaultMocks(service: any = mockService) { mockFetch.mockImplementation((url: string) => { @@ -147,10 +143,17 @@ function renderServiceDetail(id = 's1', authOverrides: { isAdmin?: boolean; user ); } +/** Helper to click a tab by its role */ +async function switchTab(name: string) { + const tab = screen.getByRole('tab', { name: new RegExp(name) }); + fireEvent.click(tab); +} + beforeEach(() => { mockFetch.mockReset(); mockUseAuth.mockReset(); mockNavigate.mockReset(); + localStorage.clear(); }); describe('ServiceDetail', () => { @@ -177,7 +180,6 @@ describe('ServiceDetail', () => { }); it('displays error state and allows retry', async () => { - // Both fetches fail on first attempt (Promise.all) mockFetch .mockRejectedValueOnce(new Error('Network error')) .mockRejectedValueOnce(new Error('Network error')); @@ -188,7 +190,6 @@ describe('ServiceDetail', () => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); - // Setup success for retry setupDefaultMocks(); fireEvent.click(screen.getByText('Retry')); @@ -212,15 +213,17 @@ describe('ServiceDetail', () => { expect(screen.getByText('Back to Services')).toBeInTheDocument(); }); - it('displays dependencies section', async () => { + it('displays dependencies section via tab', async () => { setupDefaultMocks(); renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + expect(screen.getAllByText('PostgreSQL').length).toBeGreaterThan(0); expect(screen.getByText('Main DB')).toBeInTheDocument(); expect(screen.getByText('Critical')).toBeInTheDocument(); @@ -228,15 +231,17 @@ describe('ServiceDetail', () => { expect(screen.getAllByText('cache').length).toBeGreaterThan(0); }); - it('displays dependent reports table', async () => { + it('displays dependent reports table via tab', async () => { setupDefaultMocks(); renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('Dependent Reports')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependent Reports'); + expect(screen.getByText('API Gateway')).toBeInTheDocument(); expect(screen.getByText('test-service')).toBeInTheDocument(); expect(screen.getByText('10ms')).toBeInTheDocument(); @@ -249,8 +254,12 @@ describe('ServiceDetail', () => { renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('No dependencies registered for this service.')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + + await switchTab('Dependencies'); + + expect(screen.getByText('No dependencies registered for this service.')).toBeInTheDocument(); }); it('shows empty state for no dependent reports', async () => { @@ -260,8 +269,12 @@ describe('ServiceDetail', () => { renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('No services report depending on this service.')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + + await switchTab('Dependent Reports'); + + expect(screen.getByText('No services report depending on this service.')).toBeInTheDocument(); }); it('shows inactive badge for inactive service', async () => { @@ -361,17 +374,21 @@ describe('ServiceDetail', () => { }); }); - it('displays dependency without canonical name', async () => { + it('displays dependency without canonical name via tab', async () => { setupDefaultMocks(); renderServiceDetail(); await waitFor(() => { - expect(screen.getAllByText('cache').length).toBeGreaterThan(0); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + + await switchTab('Dependencies'); + + expect(screen.getAllByText('cache').length).toBeGreaterThan(0); }); - it('displays dash for null latency', async () => { + it('displays dash for null latency via tab', async () => { setupDefaultMocks(); renderServiceDetail(); @@ -380,6 +397,8 @@ describe('ServiceDetail', () => { expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + // The cache dependency has null latency expect(screen.getAllByText('-').length).toBeGreaterThan(0); }); @@ -395,6 +414,78 @@ describe('ServiceDetail', () => { }); }); + describe('Tabs', () => { + it('renders all tabs', async () => { + setupDefaultMocks(); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Dependencies/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Dependent Reports/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Poll Issues/ })).toBeInTheDocument(); + }); + + it('shows overview tab by default', async () => { + setupDefaultMocks(); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + expect(screen.getByRole('tab', { name: /Overview/ })).toHaveAttribute('aria-selected', 'true'); + }); + + it('displays tab counts for dependencies and reports', async () => { + setupDefaultMocks(); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + expect(screen.getByRole('tab', { name: /Dependencies \(2\)/ })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Dependent Reports \(1\)/ })).toBeInTheDocument(); + }); + + it('shows empty state for external services on poll issues tab', async () => { + const externalService = { ...mockService, is_external: 1 }; + setupDefaultMocks(externalService); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Poll Issues'); + + expect(screen.getByText('Not applicable for external services.')).toBeInTheDocument(); + }); + + it('shows empty state for inactive services on poll issues tab', async () => { + const inactiveService = { ...mockService, is_active: 0 }; + setupDefaultMocks(inactiveService); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Poll Issues'); + + expect(screen.getByText('Not applicable for inactive services.')).toBeInTheDocument(); + }); + }); + describe('Contact and Override Display', () => { it('displays effective_contact as key-value pairs', async () => { const serviceWithContact = { @@ -410,6 +501,12 @@ describe('ServiceDetail', () => { renderServiceDetail(); + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Dependencies'); + await waitFor(() => { expect(screen.getByText('db-team@example.com')).toBeInTheDocument(); }); @@ -435,11 +532,16 @@ describe('ServiceDetail', () => { renderServiceDetail(); + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Dependencies'); + await waitFor(() => { expect(screen.getByText('Overridden impact value')).toBeInTheDocument(); }); - // Raw impact should not be displayed expect(screen.queryByText('Original impact')).not.toBeInTheDocument(); }); @@ -458,6 +560,12 @@ describe('ServiceDetail', () => { renderServiceDetail(); + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Dependencies'); + await waitFor(() => { expect(screen.getByText('Custom impact')).toBeInTheDocument(); }); @@ -482,6 +590,12 @@ describe('ServiceDetail', () => { renderServiceDetail(); + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Dependencies'); + await waitFor(() => { expect(screen.getByText('override@example.com')).toBeInTheDocument(); }); @@ -495,6 +609,12 @@ describe('ServiceDetail', () => { renderServiceDetail(); + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Dependencies'); + await waitFor(() => { expect(screen.getByText('Dependencies')).toBeInTheDocument(); }); @@ -516,11 +636,16 @@ describe('ServiceDetail', () => { renderServiceDetail(); + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await switchTab('Dependencies'); + await waitFor(() => { expect(screen.getByText('Dependencies')).toBeInTheDocument(); }); - // Should show dash instead of crashing expect(screen.queryByText('not-valid-json')).not.toBeInTheDocument(); }); }); @@ -532,10 +657,11 @@ describe('ServiceDetail', () => { renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); - // Rows use aria-expanded + await switchTab('Dependencies'); + const rowButtons = screen.getAllByRole('button', { expanded: false }); const depRows = rowButtons.filter( btn => btn.textContent?.includes('PostgreSQL') || btn.textContent?.includes('cache') @@ -549,18 +675,17 @@ describe('ServiceDetail', () => { renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); - // Charts should not be visible initially + await switchTab('Dependencies'); + expect(screen.queryByTestId('latency-chart-d1')).not.toBeInTheDocument(); - // Click PostgreSQL row to expand const rowButtons = screen.getAllByRole('button', { expanded: false }); const postgresRow = rowButtons.find(btn => btn.textContent?.includes('PostgreSQL')); fireEvent.click(postgresRow!); - // Charts and error history should now be visible expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument(); expect(screen.getByTestId('health-timeline-d1')).toBeInTheDocument(); expect(screen.getByTestId('error-history-panel')).toBeInTheDocument(); @@ -572,17 +697,17 @@ describe('ServiceDetail', () => { renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); - // Expand PostgreSQL row + await switchTab('Dependencies'); + const rowButtons = screen.getAllByRole('button', { expanded: false }); const postgresRow = rowButtons.find(btn => btn.textContent?.includes('PostgreSQL')); fireEvent.click(postgresRow!); expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument(); - // Click again to collapse const expandedButton = screen.getByRole('button', { expanded: true }); fireEvent.click(expandedButton); @@ -595,10 +720,11 @@ describe('ServiceDetail', () => { renderServiceDetail(); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); - // Expand both rows + await switchTab('Dependencies'); + const rowButtons = screen.getAllByRole('button', { expanded: false }); const postgresRow = rowButtons.find(btn => btn.textContent?.includes('PostgreSQL')); const cacheRow = rowButtons.find(btn => btn.textContent?.includes('cache')); @@ -606,7 +732,6 @@ describe('ServiceDetail', () => { fireEvent.click(postgresRow!); fireEvent.click(cacheRow!); - // Both charts should be visible expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument(); expect(screen.getByTestId('health-timeline-d1')).toBeInTheDocument(); expect(screen.getByTestId('latency-chart-d2')).toBeInTheDocument(); @@ -653,11 +778,13 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); - expect(editButtons.length).toBe(2); // one per dependency + expect(editButtons.length).toBe(2); }); it('shows edit buttons for team leads of the service team', async () => { @@ -666,9 +793,11 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: false, user: teamLeadUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); expect(editButtons.length).toBe(2); }); @@ -679,9 +808,11 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: false, user: memberUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + expect(screen.queryByTitle('Edit dependency')).not.toBeInTheDocument(); }); @@ -691,9 +822,11 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: false, user: noTeamUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + expect(screen.queryByTitle('Edit dependency')).not.toBeInTheDocument(); }); @@ -703,13 +836,14 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); - // Modal title includes the dependency name expect(screen.getByText(/Edit — PostgreSQL/)).toBeInTheDocument(); expect(screen.getByPlaceholderText('e.g. Critical — primary database')).toBeInTheDocument(); }); @@ -730,17 +864,17 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); - // Impact should be pre-populated const impactInput = screen.getByPlaceholderText('e.g. Critical — primary database') as HTMLInputElement; expect(impactInput.value).toBe('Critical override'); - // Contact entries should be pre-populated const keyInputs = screen.getAllByPlaceholderText('Key (e.g. email)') as HTMLInputElement[]; expect(keyInputs.length).toBe(2); expect(keyInputs[0].value).toBe('email'); @@ -757,24 +891,22 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); - // No contact entries initially expect(screen.queryByPlaceholderText('Key (e.g. email)')).not.toBeInTheDocument(); - // Add a field fireEvent.click(screen.getByText('+ Add Field')); expect(screen.getByPlaceholderText('Key (e.g. email)')).toBeInTheDocument(); - // Add another field fireEvent.click(screen.getByText('+ Add Field')); expect(screen.getAllByPlaceholderText('Key (e.g. email)').length).toBe(2); - // Remove the first field const removeButtons = screen.getAllByTitle('Remove entry'); fireEvent.click(removeButtons[0]); expect(screen.getAllByPlaceholderText('Key (e.g. email)').length).toBe(1); @@ -786,17 +918,17 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); - // Fill in impact override const impactInput = screen.getByPlaceholderText('e.g. Critical — primary database'); fireEvent.change(impactInput, { target: { value: 'New impact value' } }); - // Add a contact field fireEvent.click(screen.getByText('+ Add Field')); const keyInput = screen.getByPlaceholderText('Key (e.g. email)'); const valueInput = screen.getByPlaceholderText('Value'); @@ -806,7 +938,6 @@ describe('ServiceDetail', () => { fireEvent.click(screen.getByText('Save Overrides')); await waitFor(() => { - // PUT was called with correct body const putCall = mockFetch.mock.calls.find( (call: [string, RequestInit]) => call[1]?.method === 'PUT' && call[0].includes('/overrides') ); @@ -823,13 +954,14 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); - // Try to save without any overrides fireEvent.click(screen.getByText('Save Overrides')); await waitFor(() => { @@ -838,15 +970,16 @@ describe('ServiceDetail', () => { }); it('shows clear button only when existing overrides are active', async () => { - // No active overrides setupDefaultMocks(); renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); @@ -868,9 +1001,11 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); @@ -902,9 +1037,11 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); @@ -918,9 +1055,11 @@ describe('ServiceDetail', () => { renderServiceDetail('s1', { isAdmin: true, user: adminUser }); await waitFor(() => { - expect(screen.getByText('Dependencies')).toBeInTheDocument(); + expect(screen.getByText('Test Service')).toBeInTheDocument(); }); + await switchTab('Dependencies'); + const editButtons = screen.getAllByTitle('Edit dependency'); fireEvent.click(editButtons[0]); diff --git a/client/src/components/pages/Services/ServiceDetail.tsx b/client/src/components/pages/Services/ServiceDetail.tsx index 909043e..42b735e 100644 --- a/client/src/components/pages/Services/ServiceDetail.tsx +++ b/client/src/components/pages/Services/ServiceDetail.tsx @@ -1,10 +1,20 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams, Link } from 'react-router-dom'; +import { + ChevronLeft, + Maximize2, + RefreshCw, + Pencil, + Trash2, + FileCode2, + Loader2, +} from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { useServiceDetail } from '../../../hooks/useServiceDetail'; import StatusBadge from '../../common/StatusBadge'; import Modal from '../../common/Modal'; import ConfirmDialog from '../../common/ConfirmDialog'; +import { Tabs, TabList, Tab, TabPanel } from '../../common/Tabs'; import ServiceForm from './ServiceForm'; import DependencyList from './DependencyList'; import PollIssuesSection from './PollIssuesSection'; @@ -31,10 +41,6 @@ function ServiceDetail() { const [isEditModalOpen, setIsEditModalOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - /** - * Check if the current user can edit overrides for this service. - * Admin or team lead of the service's owning team. - */ const canEditOverrides = useCallback((): boolean => { if (isAdmin) return true; if (!user || !service) return false; @@ -46,18 +52,13 @@ function ServiceDetail() { loadService(); }, [loadService]); - /* istanbul ignore next -- @preserve - handleEditSuccess is triggered by ServiceForm onSuccess inside a Modal. Testing this - requires mocking HTMLDialogElement.showModal/close and form submission flows. - Integration tests with Cypress/Playwright are more appropriate. */ + /* istanbul ignore next -- @preserve */ const handleEditSuccess = () => { setIsEditModalOpen(false); loadService(); }; - /* istanbul ignore next -- @preserve - handleDeleteConfirm is triggered by ConfirmDialog onConfirm callback. - Integration tests are more appropriate for testing dialog flows. */ + /* istanbul ignore next -- @preserve */ const handleDeleteConfirm = async () => { await handleDelete(); setIsDeleteDialogOpen(false); @@ -103,16 +104,7 @@ function ServiceDetail() { return (
- - - + Back to Services @@ -122,218 +114,204 @@ function ServiceDetail() {
)} -
-
-

{service.name}

- - {service.manifest_managed === 1 && ( - M - )} - {!service.is_active && Inactive} -
-
- - - - - View in Graph - - - {isAdmin && ( - <> - + + View in Graph + - - )} -
-
+ {isAdmin && ( + <> + + + + )} +
+
-
-
-
- Team - {service.team.name} +
+
+
+ Team + {service.team.name} +
+
+ Health Endpoint + + + {service.health_endpoint} + + +
+ {service.metrics_endpoint && ( +
+ Metrics Endpoint + + + {service.metrics_endpoint} + + +
+ )} + {service.last_poll_success === 0 && ( +
+ Poll Status + + Failed: {service.last_poll_error || 'Unknown error'} + +
+ )} +
+ Last Updated + {formatRelativeTime(service.updated_at)} +
+ {service.manifest_managed === 1 && ( +
+ Manifest + + + Managed by manifest{service.manifest_key ? ` · Key: ${service.manifest_key}` : ''} + +
+ )} +
-
- Health Endpoint - - - {service.health_endpoint} - + + + {/* Dependencies Tab */} + + + + + {/* Dependent Reports Tab */} + +
+

Dependent Reports

+ + {service.health.healthy_reports}/{service.health.total_reports} healthy reports from {service.health.dependent_count} service{service.health.dependent_count !== 1 ? 's' : ''}
- {service.metrics_endpoint && ( -
- Metrics Endpoint - - - {service.metrics_endpoint} - - + + {service.dependent_reports.length === 0 ? ( +
+

No services report depending on this service.

- )} - {service.last_poll_success === 0 && ( -
- Poll Status - - Failed: {service.last_poll_error || 'Unknown error'} - + ) : ( +
+ + + + + + + + + + + + {service.dependent_reports.map((report) => ( + + + + + + + + ))} + +
Reporting ServiceDependency NameStatusLatencyLast Checked
+ + {report.reporting_service_name} + + {report.dependency_name} + + + {report.latency_ms !== null ? `${Math.round(report.latency_ms)}ms` : '-'} + + {formatRelativeTime(report.last_checked)} +
)} -
- Last Updated - {formatRelativeTime(service.updated_at)} -
- {service.manifest_managed === 1 && ( -
- Manifest - - - - - Managed by manifest{service.manifest_key ? ` · Key: ${service.manifest_key}` : ''} - + + + {/* Poll Issues Tab */} + + {service.is_active && !service.is_external ? ( + + ) : ( +
+

+ {service.is_external + ? 'Not applicable for external services.' + : 'Not applicable for inactive services.'} +

)} -
-
- -
-

Dependent Reports

- - {service.health.healthy_reports}/{service.health.total_reports} healthy reports from {service.health.dependent_count} service{service.health.dependent_count !== 1 ? 's' : ''} - -
- - {service.dependent_reports.length === 0 ? ( -
-

No services report depending on this service.

-
- ) : ( -
- - - - - - - - - - - - {service.dependent_reports.map((report) => ( - - - - - - - - ))} - -
Reporting ServiceDependency NameStatusLatencyLast Checked
- - {report.reporting_service_name} - - {report.dependency_name} - - - {report.latency_ms !== null ? `${Math.round(report.latency_ms)}ms` : '-'} - - {formatRelativeTime(report.last_checked)} -
-
- )} - - - - {service.is_active && !service.is_external && ( - - )} + + -
); } From b92aa196ed5eec753c3f72c7e4d57ac801774203 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 21:13:08 -0700 Subject: [PATCH 08/21] DPS-71: Add dependency detail modal with latency chart and contact info Clicking a dependency name now opens a focused detail modal showing health status, metadata, latency chart, and contact information. Lead/admin users get an Edit Overrides button that transitions to the existing edit modal. --- .../Services/DependencyDetailModal.module.css | 163 ++++++++++++++++++ .../pages/Services/DependencyDetailModal.tsx | 152 ++++++++++++++++ .../pages/Services/DependencyList.module.css | 8 +- .../pages/Services/DependencyList.tsx | 18 +- .../pages/Services/DependencyRow.tsx | 12 +- .../pages/Services/ServiceDetail.tsx | 1 + 6 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 client/src/components/pages/Services/DependencyDetailModal.module.css create mode 100644 client/src/components/pages/Services/DependencyDetailModal.tsx diff --git a/client/src/components/pages/Services/DependencyDetailModal.module.css b/client/src/components/pages/Services/DependencyDetailModal.module.css new file mode 100644 index 0000000..004ae61 --- /dev/null +++ b/client/src/components/pages/Services/DependencyDetailModal.module.css @@ -0,0 +1,163 @@ +/* Dependency Detail Modal */ + +.header { + display: flex; + align-items: center; + gap: var(--space-3); + margin-bottom: var(--space-4); +} + +.headerInfo { + flex: 1; + min-width: 0; +} + +.depName { + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0; + line-height: var(--line-height-tight); +} + +.targetService { + font-size: var(--font-sm); + color: var(--color-text-muted); + margin-top: var(--space-1); +} + +/* Sections */ +.section { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.section + .section { + margin-top: var(--space-5); + padding-top: var(--space-5); + border-top: 1px solid var(--color-border-subtle); +} + +.sectionTitle { + font-size: var(--font-xs); + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + margin: 0; +} + +/* Metadata grid */ +.metadataGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); +} + +.metadataItem { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.metadataLabel { + font-size: var(--font-xs); + font-weight: var(--font-medium); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.metadataValue { + font-size: var(--font-base); + color: var(--color-text); + font-variant-numeric: tabular-nums; +} + +/* Contact info */ +.contactList { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.contactItem { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.contactKey { + font-size: var(--font-xs); + font-weight: var(--font-medium); + color: var(--color-text-muted); + text-transform: capitalize; +} + +.contactValue { + font-size: var(--font-base); + color: var(--color-text); + word-break: break-all; +} + +/* Override indicator */ +.overrideBadge { + display: inline-block; + padding: 0.0625rem var(--space-2); + font-size: 0.625rem; + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-accent); + background-color: color-mix(in srgb, var(--color-accent) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); + border-radius: 9999px; + margin-left: var(--space-2); +} + +/* Chart container */ +.chartContainer { + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + padding: var(--space-3); + background: var(--color-surface); +} + +/* Empty contact state */ +.emptyText { + font-size: var(--font-sm); + color: var(--color-text-muted); +} + +/* Edit button at bottom */ +.editAction { + display: flex; + justify-content: flex-end; + padding-top: var(--space-4); + border-top: 1px solid var(--color-border-subtle); + margin-top: var(--space-5); +} + +.editButton { + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-secondary); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; +} + +.editButton:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} diff --git a/client/src/components/pages/Services/DependencyDetailModal.tsx b/client/src/components/pages/Services/DependencyDetailModal.tsx new file mode 100644 index 0000000..fe0c365 --- /dev/null +++ b/client/src/components/pages/Services/DependencyDetailModal.tsx @@ -0,0 +1,152 @@ +import { Pencil } from 'lucide-react'; +import type { Dependency } from '../../../types/service'; +import { parseContact, hasActiveOverride } from '../../../utils/dependency'; +import { getHealthStateBadgeStatus } from '../../../utils/statusMapping'; +import { formatRelativeTime } from '../../../utils/formatting'; +import StatusBadge from '../../common/StatusBadge'; +import Modal from '../../common/Modal'; +import { LatencyChart } from '../../Charts'; +import styles from './DependencyDetailModal.module.css'; + +interface DependencyDetailModalProps { + dep: Dependency | null; + serviceName: string; + serviceId: string; + onClose: () => void; + onEdit?: () => void; +} + +function DependencyDetailModal({ + dep, + serviceName, + serviceId, + onClose, + onEdit, +}: DependencyDetailModalProps) { + if (!dep) return null; + + const displayName = dep.canonical_name || dep.name; + const contact = parseContact(dep.effective_contact); + const overrideActive = hasActiveOverride(dep); + + return ( + + {/* Header with status */} +
+
+
+ Dependency of {serviceName} +
+
+ +
+ + {/* Metadata */} +
+

Details

+
+
+ Name + {dep.name} +
+ {dep.canonical_name && ( +
+ Canonical Name + {dep.canonical_name} +
+ )} +
+ Latency + + {dep.latency_ms !== null ? `${Math.round(dep.latency_ms)}ms` : '-'} + +
+
+ Last Checked + + {dep.last_checked ? formatRelativeTime(dep.last_checked) : '-'} + +
+ {dep.description && ( +
+ Description + {dep.description} +
+ )} + {dep.effective_impact && ( +
+ + Impact + {overrideActive && dep.impact_override && ( + override + )} + + {dep.effective_impact} +
+ )} +
+
+ + {/* Latency Chart */} +
+

Latency

+
+ +
+
+ + {/* Contact Info */} +
+

+ Contact + {overrideActive && dep.contact_override && ( + override + )} +

+ {contact ? ( +
    + {Object.entries(contact).map(([key, value]) => ( +
  • + {key} + {String(value)} +
  • + ))} +
+ ) : ( + No contact information available. + )} +
+ + {/* Edit action for lead/admin */} + {onEdit && ( +
+ +
+ )} +
+ ); +} + +export default DependencyDetailModal; diff --git a/client/src/components/pages/Services/DependencyList.module.css b/client/src/components/pages/Services/DependencyList.module.css index a627656..ae57521 100644 --- a/client/src/components/pages/Services/DependencyList.module.css +++ b/client/src/components/pages/Services/DependencyList.module.css @@ -87,7 +87,13 @@ .nameMain { font-weight: 500; font-size: 0.875rem; - color: var(--color-text-primary); + color: var(--color-accent); + cursor: pointer; + transition: color var(--duration-fast) ease; +} + +.nameMain:hover { + text-decoration: underline; } .nameSub { diff --git a/client/src/components/pages/Services/DependencyList.tsx b/client/src/components/pages/Services/DependencyList.tsx index d3c44e5..afc34ef 100644 --- a/client/src/components/pages/Services/DependencyList.tsx +++ b/client/src/components/pages/Services/DependencyList.tsx @@ -9,18 +9,21 @@ import type { Dependency } from '../../../types/service'; import type { Association } from '../../../types/association'; import DependencyRow from './DependencyRow'; import DependencyEditModal from './DependencyEditModal'; +import DependencyDetailModal from './DependencyDetailModal'; import styles from './DependencyList.module.css'; interface DependencyListProps { serviceId: string; + serviceName: string; dependencies: Dependency[]; canEditOverrides: boolean; onServiceReload: () => Promise; } -function DependencyList({ serviceId, dependencies, canEditOverrides, onServiceReload }: DependencyListProps) { +function DependencyList({ serviceId, serviceName, dependencies, canEditOverrides, onServiceReload }: DependencyListProps) { const [expandedRows, setExpandedRows] = useState>(new Set()); const [editingDep, setEditingDep] = useState(null); + const [viewingDep, setViewingDep] = useState(null); const [assocMap, setAssocMap] = useState>({}); const { @@ -155,6 +158,7 @@ function DependencyList({ serviceId, dependencies, canEditOverrides, onServiceRe isExpanded={expandedRows.has(dep.id)} onToggleExpand={() => toggleRow(dep.id)} onEdit={() => setEditingDep(dep)} + onViewDetail={() => setViewingDep(dep)} canEdit={canEditOverrides} associations={assocMap[dep.id] || []} alias={findAliasForDep(dep.name)} @@ -176,6 +180,18 @@ function DependencyList({ serviceId, dependencies, canEditOverrides, onServiceRe onRemoveAssociation={handleRemoveAssociation} onAssociationAdded={handleAssociationAdded} /> + + setViewingDep(null)} + onEdit={canEditOverrides ? () => { + const dep = viewingDep; + setViewingDep(null); + setEditingDep(dep); + } : undefined} + /> ); } diff --git a/client/src/components/pages/Services/DependencyRow.tsx b/client/src/components/pages/Services/DependencyRow.tsx index 576c94a..1fbe1af 100644 --- a/client/src/components/pages/Services/DependencyRow.tsx +++ b/client/src/components/pages/Services/DependencyRow.tsx @@ -14,6 +14,7 @@ interface DependencyRowProps { isExpanded: boolean; onToggleExpand: () => void; onEdit: () => void; + onViewDetail: () => void; canEdit: boolean; associations: Association[]; alias: DependencyAlias | undefined; @@ -25,6 +26,7 @@ function DependencyRow({ isExpanded, onToggleExpand, onEdit, + onViewDetail, canEdit, associations, alias, @@ -48,7 +50,15 @@ function DependencyRow({ }} >
- + { + e.stopPropagation(); + onViewDetail(); + }} + > {dep.canonical_name || dep.name} {dep.canonical_name && ( diff --git a/client/src/components/pages/Services/ServiceDetail.tsx b/client/src/components/pages/Services/ServiceDetail.tsx index 42b735e..8b37bd7 100644 --- a/client/src/components/pages/Services/ServiceDetail.tsx +++ b/client/src/components/pages/Services/ServiceDetail.tsx @@ -236,6 +236,7 @@ function ServiceDetail() { Date: Thu, 5 Mar 2026 21:18:55 -0700 Subject: [PATCH 09/21] DPS-71: Add tests for URL param tab selection and dependency detail modal --- .../pages/Services/ServiceDetail.test.tsx | 184 +++++++++++++++++- 1 file changed, 182 insertions(+), 2 deletions(-) diff --git a/client/src/components/pages/Services/ServiceDetail.test.tsx b/client/src/components/pages/Services/ServiceDetail.test.tsx index 401df06..bd9094b 100644 --- a/client/src/components/pages/Services/ServiceDetail.test.tsx +++ b/client/src/components/pages/Services/ServiceDetail.test.tsx @@ -130,11 +130,16 @@ function setupDefaultMocks(service: any = mockService) { }); } -function renderServiceDetail(id = 's1', authOverrides: { isAdmin?: boolean; user?: Record } = {}) { +function renderServiceDetail( + id = 's1', + authOverrides: { isAdmin?: boolean; user?: Record | null } = {}, + initialTab?: string, +) { const { isAdmin = false, user = null } = authOverrides; mockUseAuth.mockReturnValue({ isAdmin, user }); + const path = initialTab ? `/services/${id}?tab=${initialTab}` : `/services/${id}`; return render( - + } /> Services List
} /> @@ -1134,4 +1139,179 @@ describe('ServiceDetail', () => { expect(screen.queryByText(/Key:/)).not.toBeInTheDocument(); }); }); + + describe('URL param tab selection', () => { + it('respects URL param for initial tab', async () => { + setupDefaultMocks(); + + renderServiceDetail('s1', {}, 'dependencies'); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Dependencies/ })).toHaveAttribute('aria-selected', 'true'); + }); + + expect(screen.getByText('Dependencies')).toBeInTheDocument(); + }); + + it('respects URL param for reports tab', async () => { + setupDefaultMocks(); + + renderServiceDetail('s1', {}, 'reports'); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Dependent Reports/ })).toHaveAttribute('aria-selected', 'true'); + }); + + expect(screen.getByText('API Gateway')).toBeInTheDocument(); + }); + + it('respects URL param for poll-issues tab', async () => { + setupDefaultMocks(); + + renderServiceDetail('s1', {}, 'poll-issues'); + + await waitFor(() => { + expect(screen.getByRole('tab', { name: /Poll Issues/ })).toHaveAttribute('aria-selected', 'true'); + }); + }); + }); + + describe('Dependency detail modal', () => { + /** Click the dependency name span (role="link") to open the detail modal */ + async function openDepDetailModal(name: string) { + await switchTab('Dependencies'); + const nameSpan = screen.getAllByRole('link', { hidden: true }).find(el => el.textContent === name); + fireEvent.click(nameSpan!); + await waitFor(() => { + expect(screen.getByText(`Dependency of Test Service`)).toBeInTheDocument(); + }); + } + + it('opens dependency detail modal when dependency name is clicked', async () => { + setupDefaultMocks(); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await openDepDetailModal('PostgreSQL'); + + expect(screen.getByText('Details')).toBeInTheDocument(); + // Latency section heading and chart present + const latencyHeadings = screen.getAllByText('Latency'); + expect(latencyHeadings.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('Contact')).toBeInTheDocument(); + expect(screen.getByTestId('latency-chart-d1')).toBeInTheDocument(); + }); + + it('shows latency value in detail modal', async () => { + setupDefaultMocks(); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await openDepDetailModal('PostgreSQL'); + + // 15ms appears in both the row and modal; just confirm it's present + expect(screen.getAllByText('15ms').length).toBeGreaterThanOrEqual(1); + }); + + it('shows contact info in detail modal', async () => { + const serviceWithContact = { + ...mockService, + dependencies: [ + { + ...mockService.dependencies[0], + effective_contact: '{"email":"detail@example.com","slack":"#detail-support"}', + }, + ], + }; + setupDefaultMocks(serviceWithContact); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await openDepDetailModal('PostgreSQL'); + + // Contact values rendered in the modal + expect(screen.getAllByText('detail@example.com').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('#detail-support').length).toBeGreaterThanOrEqual(1); + }); + + it('shows "No contact information" when no contact exists', async () => { + setupDefaultMocks(); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await openDepDetailModal('PostgreSQL'); + + expect(screen.getByText('No contact information available.')).toBeInTheDocument(); + }); + + it('shows edit overrides button for admin users', async () => { + const adminUser = { id: 'u1', email: 'admin@test.com', name: 'Admin', role: 'admin', teams: [] }; + setupDefaultMocks(); + + renderServiceDetail('s1', { isAdmin: true, user: adminUser }); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await openDepDetailModal('PostgreSQL'); + + expect(screen.getByText('Edit Overrides')).toBeInTheDocument(); + }); + + it('hides edit overrides button for non-privileged users', async () => { + setupDefaultMocks(); + + renderServiceDetail('s1', { isAdmin: false, user: null }); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await openDepDetailModal('PostgreSQL'); + + expect(screen.queryByText('Edit Overrides')).not.toBeInTheDocument(); + }); + + it('shows override badge in detail modal when impact override active', async () => { + const serviceWithOverride = { + ...mockService, + dependencies: [ + { + ...mockService.dependencies[0], + impact_override: 'Custom modal impact', + effective_impact: 'Custom modal impact', + }, + ], + }; + setupDefaultMocks(serviceWithOverride); + + renderServiceDetail(); + + await waitFor(() => { + expect(screen.getByText('Test Service')).toBeInTheDocument(); + }); + + await openDepDetailModal('PostgreSQL'); + + // The modal shows the effective impact value + expect(screen.getAllByText('Custom modal impact').length).toBeGreaterThanOrEqual(1); + }); + }); }); From f80d85a2242fbbd1b421b0c443c97c95820486de Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 21:28:57 -0700 Subject: [PATCH 10/21] DPS-72: Polish graph toolbar, settings dropdown, and side panels Replace inline SVGs with Lucide React icons throughout the graph page. Apply design system tokens (spacing, typography, colors, transitions) to toolbar, settings dropdown, NodeDetailsPanel, and EdgeDetailsPanel. Add slide-in animation and slim scrollbar to side panels. Add grouped section headers to settings dropdown. --- .../DependencyGraph.module.css | 316 ++++++++++-------- .../pages/DependencyGraph/DependencyGraph.tsx | 78 ++--- .../EdgeDetailsPanel.module.css | 280 ++++++++-------- .../DependencyGraph/EdgeDetailsPanel.tsx | 42 +-- .../NodeDetailsPanel.module.css | 272 ++++++++------- .../DependencyGraph/NodeDetailsPanel.tsx | 22 +- 6 files changed, 527 insertions(+), 483 deletions(-) diff --git a/client/src/components/pages/DependencyGraph/DependencyGraph.module.css b/client/src/components/pages/DependencyGraph/DependencyGraph.module.css index ead9777..a766a95 100644 --- a/client/src/components/pages/DependencyGraph/DependencyGraph.module.css +++ b/client/src/components/pages/DependencyGraph/DependencyGraph.module.css @@ -12,73 +12,94 @@ .toolbar { display: flex; align-items: center; - gap: 16px; - padding: 12px 20px; - background: var(--color-bg-card); + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background: var(--color-surface); border-bottom: 1px solid var(--color-border); } .toolbarGroup { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); } .toolbarLabel { - font-size: 14px; + font-size: var(--font-sm); color: var(--color-text-muted); - font-weight: 500; + font-weight: var(--font-medium); } .select { - padding: 8px 12px; - border: 1px solid var(--color-border-input); - border-radius: 6px; - font-size: 14px; - background: var(--color-bg-input); - color: var(--color-text-primary); + font-size: var(--font-base); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); min-width: 150px; + transition: border-color var(--duration-fast) ease; } -.select:focus { +.select:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.searchWrapper { + position: relative; + display: flex; + align-items: center; +} + +.searchIcon { + position: absolute; + left: var(--space-2); + color: var(--color-text-muted); + pointer-events: none; } .searchInput { - padding: 8px 12px; - border: 1px solid var(--color-border-input); - border-radius: 6px; - font-size: 14px; + font-size: var(--font-sm); + padding: var(--space-2) var(--space-3); + padding-left: calc(var(--space-2) + 16px + var(--space-1)); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); width: 200px; - background: var(--color-bg-input); - color: var(--color-text-primary); + background: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease; } -.searchInput:focus { +.searchInput:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.searchInput::placeholder { + color: var(--color-text-muted); } .toolbarButton { display: flex; align-items: center; - gap: 6px; - padding: 8px 12px; - border: 1px solid var(--color-border-input); - border-radius: 6px; - background: var(--color-bg-card); - color: var(--color-text-primary); - font-size: 14px; + gap: var(--space-2); + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); cursor: pointer; - transition: all 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .toolbarButton:hover { - background: var(--color-bg-hover); - border-color: var(--color-text-muted); + background: var(--color-surface-hover); + color: var(--color-text); } .toolbarButton svg { @@ -86,23 +107,25 @@ height: 16px; } -/* Exit isolation button */ +/* Exit isolation button — ghost style */ .exitIsolationButton { display: flex; align-items: center; - gap: 6px; - padding: 8px 12px; - border: 1px solid var(--color-accent); - border-radius: 6px; - background: var(--color-accent); - color: var(--color-text-inverse); - font-size: 14px; + gap: var(--space-2); + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); cursor: pointer; - transition: all 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .exitIsolationButton:hover { - opacity: 0.9; + background: var(--color-surface-hover); + color: var(--color-text); } .exitIsolationButton svg { @@ -112,32 +135,32 @@ /* Context menu */ .contextMenu { - z-index: 1000; - background: var(--color-bg-card); + z-index: var(--z-dropdown); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 8px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - padding: 4px; + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + padding: var(--space-1); min-width: 160px; } .contextMenuItem { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); width: 100%; - padding: 8px 12px; + padding: var(--space-2) var(--space-3); border: none; - border-radius: 6px; + border-radius: var(--radius-sm); background: none; - color: var(--color-text-primary); - font-size: 13px; + color: var(--color-text); + font-size: var(--font-sm); cursor: pointer; - transition: background 0.1s; + transition: background-color var(--duration-fast) ease; } .contextMenuItem:hover { - background: var(--color-bg-hover); + background: var(--color-surface-hover); } .contextMenuItem svg { @@ -148,7 +171,7 @@ .toolbarRight { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); margin-left: auto; } @@ -161,31 +184,30 @@ display: flex; align-items: center; justify-content: center; - padding: 6px; + padding: var(--space-1); background: none; border: 1px solid transparent; - border-radius: 6px; + border-radius: var(--radius-sm); cursor: pointer; color: var(--color-text-muted); - transition: all 0.15s; + transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease; } .legendButton:hover { - color: var(--color-text-primary); - background: var(--color-bg-hover); - border-color: var(--color-border-input); + color: var(--color-text); + background: var(--color-surface-hover); } .legendTooltip { display: none; position: absolute; - top: calc(100% + 8px); + top: calc(100% + var(--space-2)); right: 0; - z-index: 50; - padding: 12px 16px; - background: var(--color-bg-card); + z-index: var(--z-tooltip); + padding: var(--space-3) var(--space-4); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: var(--shadow-md); white-space: nowrap; } @@ -194,7 +216,7 @@ .legendButton:focus + .legendTooltip { display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } /* Settings menu */ @@ -206,54 +228,62 @@ display: flex; align-items: center; justify-content: center; - padding: 6px; - background: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 6px; + padding: var(--space-1); + background: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - color: var(--color-text-primary); - transition: all 0.15s; + color: var(--color-text-secondary); + transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease; } .settingsButton:hover { - background: var(--color-bg-hover); - border-color: var(--color-text-muted); + background: var(--color-surface-hover); + color: var(--color-text); } .settingsMenu { position: absolute; - top: calc(100% + 8px); + top: calc(100% + var(--space-2)); right: 0; - z-index: 50; - padding: 12px; - background: var(--color-bg-card); + z-index: var(--z-dropdown); + padding: var(--space-3); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: var(--shadow-md); display: flex; flex-direction: column; - gap: 12px; - min-width: 220px; + gap: var(--space-3); + min-width: 240px; } .settingsMenuItem { display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: var(--space-3); } -.settingsMenuLabel { - font-size: 13px; - font-weight: 500; +.settingsSectionHeader { + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.settingsMenuLabel { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-secondary); white-space: nowrap; } .settingsMenuDivider { height: 1px; - background: var(--color-border); - margin: 0 -4px; + background: var(--color-border-subtle); + margin: 0 calc(-1 * var(--space-1)); } /* Legend (shared styles for tooltip items) */ @@ -261,17 +291,20 @@ .legendItem { display: flex; align-items: center; - gap: 6px; + gap: var(--space-2); + font-size: var(--font-xs); + color: var(--color-text-secondary); } .legendDot { - width: 10px; - height: 10px; + width: 8px; + height: 8px; border-radius: 50%; + flex-shrink: 0; } .legendDot.healthy { - background: var(--color-success); + background: var(--color-healthy); } .legendDot.warning { @@ -279,15 +312,15 @@ } .legendDot.critical { - background: var(--color-error); + background: var(--color-critical); } .legendDot.unknown { - background: var(--color-text-muted); + background: var(--color-unknown); } .legendDot.skipped { - background: var(--color-border-input); + background: var(--color-border); border: 1px dashed var(--color-text-muted); } @@ -315,8 +348,9 @@ align-items: center; justify-content: center; height: 100%; - gap: 16px; + gap: var(--space-4); color: var(--color-text-muted); + font-size: var(--font-base); } .spinner { @@ -335,17 +369,19 @@ } .error { - color: var(--color-error); + color: var(--color-critical); } .retryButton { - padding: 8px 16px; + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); background: var(--color-accent); - color: var(--color-text-inverse); + color: #fff; border: none; - border-radius: 6px; + border-radius: var(--radius-sm); cursor: pointer; - font-size: 14px; + transition: background-color var(--duration-fast) ease; } .retryButton:hover { @@ -642,13 +678,15 @@ justify-content: center; height: 100%; color: var(--color-text-muted); - gap: 12px; + gap: var(--space-3); + font-size: var(--font-base); } .emptyState svg { - width: 64px; - height: 64px; - color: var(--color-border-input); + width: 48px; + height: 48px; + color: var(--color-text-muted); + opacity: 0.5; } /* Controls override */ @@ -682,27 +720,16 @@ .autoRefreshControls { display: flex; align-items: center; - gap: 0.5rem; -} - -.autoRefreshLabel { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-muted); - white-space: nowrap; + gap: var(--space-2); } .refreshingIndicator { display: flex; align-items: center; + color: var(--color-accent); } -.spinnerSmall { - width: 14px; - height: 14px; - border: 2px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; +.refreshingIndicator svg { animation: spin 1s linear infinite; } @@ -711,20 +738,20 @@ width: 2.5rem; height: 1.5rem; padding: 0; - background-color: var(--color-border-input); + background-color: var(--color-border); border: none; border-radius: 1rem; cursor: pointer; - transition: background-color 0.2s; + transition: background-color var(--duration-normal) ease; } -.togglePill:focus { +.togglePill:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } .togglePill.toggleActive { - background-color: var(--color-success); + background-color: var(--color-accent); } .toggleKnob { @@ -733,10 +760,10 @@ left: 0.125rem; width: 1.25rem; height: 1.25rem; - background-color: var(--color-bg-card); + background-color: #fff; border-radius: 50%; - transition: transform 0.2s; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + transition: transform var(--duration-normal) ease; + box-shadow: var(--shadow-sm); } .togglePill.toggleActive .toggleKnob { @@ -744,19 +771,20 @@ } .intervalSelect { - padding: 0.25rem 0.5rem; - font-size: 0.8125rem; - border: 1px solid var(--color-border-input); - border-radius: 4px; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + font-size: var(--font-sm); + padding: var(--space-1) var(--space-2); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); cursor: pointer; + transition: border-color var(--duration-fast) ease; } -.intervalSelect:focus { +.intervalSelect:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .intervalSelect:disabled { @@ -764,11 +792,11 @@ cursor: not-allowed; } -/* Direction toggle */ +/* Segmented pill toggle */ .directionToggle { display: flex; - border: 1px solid var(--color-border-input); - border-radius: 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); overflow: hidden; } @@ -776,25 +804,25 @@ display: flex; align-items: center; justify-content: center; - padding: 6px 10px; - background: var(--color-bg-card); + padding: var(--space-1) var(--space-2); + background: var(--color-surface); border: none; cursor: pointer; - transition: all 0.15s; - color: var(--color-text-primary); + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; + color: var(--color-text-secondary); } .directionButton:first-child { - border-right: 1px solid var(--color-border-input); + border-right: 1px solid var(--color-border); } .directionButton:hover { - background: var(--color-bg-hover); + background: var(--color-surface-hover); } .directionButton.directionActive { background: var(--color-accent); - color: var(--color-text-inverse); + color: #fff; } .directionButton.directionActive:hover { diff --git a/client/src/components/pages/DependencyGraph/DependencyGraph.tsx b/client/src/components/pages/DependencyGraph/DependencyGraph.tsx index 183cac3..6ba9564 100644 --- a/client/src/components/pages/DependencyGraph/DependencyGraph.tsx +++ b/client/src/components/pages/DependencyGraph/DependencyGraph.tsx @@ -14,6 +14,18 @@ import { } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import { + Search, + Maximize2, + Loader2, + Info, + Settings, + ArrowDown, + ArrowRight, + CornerDownRight, + RotateCcw, + Shrink, +} from 'lucide-react'; import { getServiceHealthStatus } from '../../../types/graph'; import { ServiceNode } from './ServiceNode'; import { CustomEdge } from './CustomEdge'; @@ -418,13 +430,16 @@ function DependencyGraphInner() {
- setSearchQuery(e.target.value)} - /> +
+ + setSearchQuery(e.target.value)} + /> +
{isolationTarget && ( @@ -434,9 +449,7 @@ function DependencyGraphInner() { onClick={exitIsolation} title="Exit isolated view and show all nodes" > - - - + Show full graph
@@ -445,7 +458,7 @@ function DependencyGraphInner() {
{isRefreshing && (
-
+
)} @@ -455,10 +468,7 @@ function DependencyGraphInner() { title="Legend" aria-label="Show legend" > - - - - +
@@ -496,55 +506,47 @@ function DependencyGraphInner() { aria-label="Graph settings" aria-expanded={settingsOpen} > - - - - + {settingsOpen && (
+
Layout
- + Direction
- + Edges
@@ -556,16 +558,15 @@ function DependencyGraphInner() { className={styles.toolbarButton} onClick={resetLayout} title="Reset to auto-layout" + style={{ width: '100%' }} > - - - - + Reset Layout
+
Animation
Dashed edges @@ -592,9 +593,10 @@ function DependencyGraphInner() {
+
Refresh
- Auto-refresh + Auto-refresh
diff --git a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css index 06af0f5..5db5a10 100644 --- a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css +++ b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css @@ -1,42 +1,67 @@ +/* Side panel — matches NodeDetailsPanel */ .panel { position: absolute; top: 0; right: 0; bottom: 0; width: 320px; - background: var(--color-bg-card); + background: var(--color-surface); border-left: 1px solid var(--color-border); + box-shadow: var(--shadow-md); display: flex; flex-direction: column; overflow: hidden; + animation: slideIn var(--duration-normal) ease; } +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Slim scrollbar */ .scrollContent { flex: 1; min-height: 0; overflow-y: auto; - scrollbar-width: none; /* Firefox */ - -ms-overflow-style: none; /* IE/Edge */ + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; } .scrollContent::-webkit-scrollbar { - display: none; /* Chrome/Safari/Opera */ + width: 4px; +} + +.scrollContent::-webkit-scrollbar-track { + background: transparent; } +.scrollContent::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 2px; +} + +/* Header */ .header { display: flex; align-items: center; justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); flex-shrink: 0; } .title { margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--color-text-primary); + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -46,38 +71,39 @@ display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 28px; + height: 28px; padding: 0; border: none; background: transparent; color: var(--color-text-muted); cursor: pointer; - border-radius: 6px; - transition: all 0.15s; + border-radius: var(--radius-sm); + transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease; flex-shrink: 0; } .closeButton:hover { - background: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text); } +/* Status section */ .statusSection { display: flex; flex-wrap: wrap; - gap: 8px; - padding: 16px 20px 0 20px; + gap: var(--space-2); + padding: var(--space-4) var(--space-4) 0 var(--space-4); } .statusBadge { display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 20px; - font-size: 13px; - font-weight: 500; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--font-sm); + font-weight: var(--font-medium); } .statusBadge.healthy { @@ -106,14 +132,13 @@ } .statusBadge.unknown { - background: var(--color-bg-hover); + background: var(--color-surface-hover); color: var(--color-text-muted); } .statusBadge.highLatency { background: #fffbeb; color: #d97706; - animation: pulseHighLatency 1.5s ease-in-out infinite; } [data-theme="dark"] .statusBadge.highLatency { @@ -121,33 +146,25 @@ color: #fbbf24; } -@keyframes pulseHighLatency { - 0%, 100% { - box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); - } - 50% { - box-shadow: 0 0 10px rgba(245, 158, 11, 0.6); - } -} - .statusDot { - width: 8px; - height: 8px; + width: 6px; + height: 6px; border-radius: 50%; background: currentColor; } +/* Sections */ .section { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } .sectionTitle { - margin: 0 0 12px 0; - font-size: 12px; - font-weight: 600; + margin: 0 0 var(--space-2) 0; + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; color: var(--color-text-muted); } @@ -155,31 +172,31 @@ .connectionFlow { display: flex; align-items: center; - gap: 12px; + gap: var(--space-2); } .connectionNode { flex: 1; display: flex; flex-direction: column; - gap: 4px; - padding: 10px; - background: var(--color-bg-hover); - border-radius: 8px; + gap: 2px; + padding: var(--space-2); + background: var(--color-surface-hover); + border-radius: var(--radius-sm); } .connectionLabel { - font-size: 11px; + font-size: var(--font-xs); color: var(--color-text-muted); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; } .connectionLink { - font-size: 13px; + font-size: var(--font-sm); color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -190,8 +207,8 @@ } .connectionText { - font-size: 13px; - color: var(--color-text-primary); + font-size: var(--font-sm); + color: var(--color-text); } .connectionArrow { @@ -205,25 +222,21 @@ padding: 0; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .contactItem { display: flex; align-items: baseline; - gap: 8px; - padding: 6px 10px; - background: var(--color-bg-hover); - border-radius: 6px; + gap: var(--space-2); } .contactKey { - font-size: 12px; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); text-transform: capitalize; flex-shrink: 0; - min-width: 0; } .contactKey::after { @@ -231,8 +244,8 @@ } .contactValue { - font-size: 13px; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); margin: 0; overflow-wrap: break-word; min-width: 0; @@ -240,68 +253,74 @@ /* Latency chart section */ .chartSection { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } /* Impact */ .impactText { margin: 0; - font-size: 13px; - line-height: 1.5; - color: var(--color-text-primary); + font-size: var(--font-sm); + line-height: var(--line-height-normal); + color: var(--color-text); } /* Details grid */ .detailsGrid { display: flex; flex-direction: column; - gap: 12px; + gap: var(--space-3); } .detailItem { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; } .detailLabel { - font-size: 12px; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; } .detailValue { - font-size: 14px; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); text-transform: capitalize; } /* Actions */ .actions { - padding: 16px 20px; + padding: var(--space-3) var(--space-4); margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--space-2); } .isolateButton { display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--space-2); width: 100%; - padding: 10px 16px; - background: var(--color-bg-hover); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + background: transparent; + color: var(--color-text-secondary); border: 1px solid var(--color-border); - border-radius: 8px; - font-size: 14px; - font-weight: 500; + border-radius: var(--radius-sm); cursor: pointer; - transition: background 0.15s; - margin-bottom: 8px; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .isolateButton:hover { - background: var(--color-border); + background: var(--color-surface-hover); + color: var(--color-text); } .isolateButton svg { @@ -314,18 +333,18 @@ display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--space-2); width: 100%; - padding: 10px 16px; + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); background: var(--color-accent); - color: var(--color-text-inverse); + color: #fff; border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 500; + border-radius: var(--radius-sm); text-decoration: none; cursor: pointer; - transition: background 0.15s; + transition: background-color var(--duration-fast) ease; } .viewDetailsButton:hover { @@ -334,44 +353,45 @@ /* Error Alert Section */ .errorAlert { - margin: 0 20px 16px; - padding: 12px; + margin: 0 var(--space-4) var(--space-4); + padding: var(--space-3); background: var(--color-error-bg); - border: 1px solid var(--color-error); - border-radius: 8px; + border: 1px solid var(--color-error-border); + border-radius: var(--radius-md); } .errorAlertHeader { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); color: var(--color-error); - margin-bottom: 8px; + margin-bottom: var(--space-2); } .errorAlertTitle { - font-weight: 600; - font-size: 14px; + font-weight: var(--font-semibold); + font-size: var(--font-base); } .errorMessage { margin: 0; - font-size: 13px; - color: var(--color-text-primary); - line-height: 1.5; + font-size: var(--font-sm); + color: var(--color-text); + line-height: var(--line-height-normal); } .errorToggle { display: flex; align-items: center; - gap: 4px; - margin-top: 8px; + gap: var(--space-1); + margin-top: var(--space-2); padding: 0; border: none; background: transparent; color: var(--color-error); - font-size: 12px; + font-size: var(--font-xs); cursor: pointer; + transition: opacity var(--duration-fast) ease; opacity: 0.8; } @@ -380,12 +400,12 @@ } .errorDetails { - margin: 8px 0 0; - padding: 8px; + margin: var(--space-2) 0 0; + padding: var(--space-2); background: rgba(0, 0, 0, 0.1); - border-radius: 4px; - font-size: 11px; - font-family: monospace; + border-radius: var(--radius-sm); + font-size: var(--font-xs); + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; overflow-x: auto; white-space: pre-wrap; word-break: break-word; @@ -401,29 +421,29 @@ .checkDetailsGrid { display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .checkDetailItem { display: flex; justify-content: space-between; align-items: flex-start; - gap: 12px; - padding: 8px 10px; - background: var(--color-bg-hover); - border-radius: 6px; + gap: var(--space-3); + padding: var(--space-2); + background: var(--color-surface-hover); + border-radius: var(--radius-sm); } .checkDetailKey { - font-size: 12px; + font-size: var(--font-xs); color: var(--color-text-muted); flex-shrink: 0; } .checkDetailValue { - font-size: 12px; - color: var(--color-text-primary); - font-family: monospace; + font-size: var(--font-xs); + color: var(--color-text); + font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; text-align: right; word-break: break-word; } @@ -436,26 +456,22 @@ .viewErrorHistoryButton { display: flex; align-items: center; - justify-content: space-between; + gap: var(--space-2); width: 100%; - padding: 12px 14px; - background: var(--color-bg-hover); + padding: var(--space-2) var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); + background: transparent; + color: var(--color-text-secondary); border: 1px solid var(--color-border); - border-radius: 8px; - font-size: 13px; - font-weight: 500; - color: var(--color-text-primary); + border-radius: var(--radius-sm); cursor: pointer; - transition: all 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .viewErrorHistoryButton:hover { - background: var(--color-border); - border-color: var(--color-text-muted); -} - -.viewErrorHistoryButton svg:first-child { - margin-right: 8px; + background: var(--color-surface-hover); + color: var(--color-text); } .viewErrorHistoryButton svg:last-child { diff --git a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx index 50f8dbd..5d69439 100644 --- a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx +++ b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.tsx @@ -1,6 +1,7 @@ import { memo, useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { type Node } from '@xyflow/react'; +import { X, XCircle, ChevronDown, ArrowRight, Clock, ChevronRight, Shrink } from 'lucide-react'; import { ServiceNodeData, GraphEdgeData, getEdgeHealthStatus, HealthStatus } from '../../../types/graph'; import { LatencyChart } from '../../Charts/LatencyChart'; import { ErrorHistoryPanel } from '../../common/ErrorHistoryPanel'; @@ -86,9 +87,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs

{displayName}

@@ -112,9 +111,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs {hasError && (
- - - + Error Detected
{data.errorMessage && ( @@ -127,17 +124,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs onClick={() => setShowErrorDetails(!showErrorDetails)} > {showErrorDetails ? 'Hide' : 'Show'} error details - - - + {showErrorDetails && (
@@ -165,9 +152,7 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs
               )}
             
- - - +
To @@ -252,14 +237,9 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs className={styles.viewErrorHistoryButton} onClick={() => setCurrentView('errorHistory')} > - - - - + View Error History (24h) - - - +
)} @@ -271,18 +251,14 @@ function EdgeDetailsPanelComponent({ data, sourceNode, targetNode, onClose, onIs className={styles.isolateButton} onClick={() => onIsolate(data.dependencyId!)} > - - - + Isolate tree )} {targetNode && ( View Service Details - - - + )}
diff --git a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css index e93e6e6..6e906da 100644 --- a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css +++ b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css @@ -1,49 +1,74 @@ +/* Side panel */ .panel { position: absolute; top: 0; right: 0; bottom: 0; width: 320px; - background: var(--color-bg-card); + background: var(--color-surface); border-left: 1px solid var(--color-border); + box-shadow: var(--shadow-md); display: flex; flex-direction: column; overflow: hidden; + animation: slideIn var(--duration-normal) ease; } +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Slim scrollbar */ .scrollContent { flex: 1; min-height: 0; overflow-y: auto; - scrollbar-width: none; - -ms-overflow-style: none; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; } .scrollContent::-webkit-scrollbar { - display: none; + width: 4px; +} + +.scrollContent::-webkit-scrollbar-track { + background: transparent; } +.scrollContent::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 2px; +} + +/* Header */ .header { display: flex; align-items: center; justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } .titleRow { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); min-width: 0; flex: 1; } .title { margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--color-text-primary); + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -52,21 +77,21 @@ .externalBadge { display: inline-flex; align-items: center; - padding: 2px 8px; - font-size: 11px; - font-weight: 600; + padding: 2px var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; color: var(--color-text-muted); - background: var(--color-bg-hover); + background: var(--color-surface-hover); border: 1px dashed var(--color-border); - border-radius: 4px; + border-radius: var(--radius-sm); flex-shrink: 0; } .externalDescription { margin: 0; - font-size: 12px; + font-size: var(--font-xs); color: var(--color-text-muted); font-style: italic; } @@ -75,41 +100,42 @@ display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 28px; + height: 28px; padding: 0; border: none; background: transparent; color: var(--color-text-muted); cursor: pointer; - border-radius: 6px; - transition: all 0.15s; + border-radius: var(--radius-sm); + transition: color var(--duration-fast) ease, background-color var(--duration-fast) ease; flex-shrink: 0; } .closeButton:hover { - background: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text); } +/* Status section */ .statusSection { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .pollFailure { display: flex; align-items: center; - gap: 6px; - padding: 6px 10px; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); background-color: color-mix(in srgb, var(--color-warning) 12%, transparent); - border-radius: 6px; - font-size: 0.75rem; + border-radius: var(--radius-sm); + font-size: var(--font-xs); color: var(--color-warning); - line-height: 1.3; + line-height: var(--line-height-normal); } .pollFailure svg { @@ -119,11 +145,12 @@ .statusBadge { display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 20px; - font-size: 13px; - font-weight: 500; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--font-sm); + font-weight: var(--font-medium); + width: fit-content; } .statusBadge.healthy { @@ -152,61 +179,66 @@ } .statusBadge.unknown { - background: var(--color-bg-hover); + background: var(--color-surface-hover); color: var(--color-text-muted); } .statusDot { - width: 8px; - height: 8px; + width: 6px; + height: 6px; border-radius: 50%; background: currentColor; } +/* Sections */ .section { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } .sectionTitle { - margin: 0 0 4px 0; - font-size: 12px; - font-weight: 600; + margin: 0 0 var(--space-1) 0; + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; color: var(--color-text-muted); } .sectionDescription { - margin: 0 0 12px 0; - font-size: 12px; + margin: 0 0 var(--space-3) 0; + font-size: var(--font-xs); color: var(--color-text-muted); } +/* Details grid */ .detailsGrid { display: flex; flex-direction: column; - gap: 12px; + gap: var(--space-3); } .detailItem { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; } .detailLabel { - font-size: 12px; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; } .detailValue { - font-size: 14px; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); } .detailLink { - font-size: 14px; + font-size: var(--font-sm); color: var(--color-accent); text-decoration: none; word-break: break-all; @@ -216,89 +248,98 @@ text-decoration: underline; } +/* Stats grid */ .statsGrid { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 12px; - margin-bottom: 12px; + gap: var(--space-2); + margin-bottom: var(--space-3); } .statItem { display: flex; flex-direction: column; align-items: center; - gap: 4px; - padding: 12px 8px; - background: var(--color-bg-hover); - border-radius: 8px; + gap: 2px; + padding: var(--space-2); + background: var(--color-surface-hover); + border-radius: var(--radius-sm); } .statItem.healthy .statValue { - color: var(--color-success); + color: var(--color-healthy); } .statItem.critical .statValue { - color: var(--color-error); + color: var(--color-critical); } .statValue { - font-size: 24px; - font-weight: 600; - color: var(--color-text-primary); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); + font-variant-numeric: tabular-nums; } .statLabel { - font-size: 11px; + font-size: var(--font-xs); color: var(--color-text-muted); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; } +/* Service / dependency lists */ .serviceList { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; - gap: 8px; + gap: 1px; max-height: 200px; overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; } .serviceListItem { display: flex; align-items: center; - gap: 8px; - padding: 8px 10px; - background: var(--color-bg-hover); - border-radius: 6px; + gap: var(--space-2); + padding: var(--space-2) var(--space-2); + border-radius: var(--radius-sm); + transition: background-color var(--duration-fast) ease; +} + +.serviceListItem:hover { + background: var(--color-surface-hover); } .healthDot { - width: 8px; - height: 8px; + width: 6px; + height: 6px; border-radius: 50%; flex-shrink: 0; } .healthDot.healthy { - background: var(--color-success); + background: var(--color-healthy); } .healthDot.warning { - background: #f59e0b; + background: var(--color-warning); } .healthDot.critical { - background: var(--color-error); + background: var(--color-critical); } .healthDot.unknown { - background: var(--color-text-muted); + background: var(--color-unknown); } .serviceLink { - font-size: 13px; + font-size: var(--font-sm); color: var(--color-accent); text-decoration: none; white-space: nowrap; @@ -313,24 +354,17 @@ .dependencyLabel { display: flex; align-items: center; - gap: 4px; + gap: 2px; margin-left: auto; - font-size: 11px; + font-size: var(--font-xs); color: var(--color-text-muted); - background: var(--color-bg-card); - padding: 2px 6px; - border-radius: 4px; + font-variant-numeric: tabular-nums; flex-shrink: 0; } .dependencyLabel.highLatency { color: var(--color-warning); - font-weight: 600; - background: #fffbeb; -} - -[data-theme="dark"] .dependencyLabel.highLatency { - background: #451a03; + font-weight: var(--font-semibold); } .highLatencyIcon { @@ -343,25 +377,21 @@ padding: 0; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .contactItem { display: flex; align-items: baseline; - gap: 8px; - padding: 6px 10px; - background: var(--color-bg-hover); - border-radius: 6px; + gap: var(--space-2); } .contactKey { - font-size: 12px; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); text-transform: capitalize; flex-shrink: 0; - min-width: 0; } .contactKey::after { @@ -369,8 +399,8 @@ } .contactValue { - font-size: 13px; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); margin: 0; overflow-wrap: break-word; min-width: 0; @@ -378,35 +408,39 @@ /* Latency chart section */ .chartSection { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); } +/* Actions */ .actions { - padding: 16px 20px; + padding: var(--space-3) var(--space-4); margin-top: auto; + display: flex; + flex-direction: column; + gap: var(--space-2); } .isolateButton { display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--space-2); width: 100%; - padding: 10px 16px; - background: var(--color-bg-hover); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + background: transparent; + color: var(--color-text-secondary); border: 1px solid var(--color-border); - border-radius: 8px; - font-size: 14px; - font-weight: 500; + border-radius: var(--radius-sm); cursor: pointer; - transition: background 0.15s; - margin-bottom: 8px; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .isolateButton:hover { - background: var(--color-border); + background: var(--color-surface-hover); + color: var(--color-text); } .isolateButton svg { @@ -419,18 +453,18 @@ display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--space-2); width: 100%; - padding: 10px 16px; + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); background: var(--color-accent); - color: var(--color-text-inverse); + color: #fff; border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 500; + border-radius: var(--radius-sm); text-decoration: none; cursor: pointer; - transition: background 0.15s; + transition: background-color var(--duration-fast) ease; } .viewDetailsButton:hover { diff --git a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx index d072a93..e8251b0 100644 --- a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx +++ b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.tsx @@ -1,6 +1,7 @@ import { memo, useMemo } from 'react'; import { Link } from 'react-router-dom'; import { type Node, type Edge } from '@xyflow/react'; +import { X, AlertTriangle, ChevronRight, Shrink } from 'lucide-react'; import { ServiceNodeData, GraphEdgeData, getServiceHealthStatus, getEdgeHealthStatus, HealthStatus } from '../../../types/graph'; import { AggregateLatencyChart } from '../../Charts/AggregateLatencyChart'; import styles from './NodeDetailsPanel.module.css'; @@ -144,9 +145,7 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol {isExternal && External}
@@ -160,10 +159,7 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol
{!isExternal && data.lastPollSuccess === false && (
- - - - + Poll failed{data.lastPollError ? `: ${data.lastPollError}` : ''}
)} @@ -228,9 +224,7 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol {formatLatency(dep.latencyMs)} {dep.isHighLatency && ( - - - + )} )} @@ -300,18 +294,14 @@ function NodeDetailsPanelComponent({ nodeId, data, nodes, edges, onClose, onIsol className={styles.isolateButton} onClick={() => onIsolate(nodeId)} > - - - + Isolate tree )} {!isExternal && ( View Full Details - - - + )}
From cfd4faf44ef148a4fc6dc04c19e6ee240bafcf03 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 21:33:04 -0700 Subject: [PATCH 11/21] DPS-74: Polish Services list page with design tokens and Lucide icons --- .../pages/Services/Services.module.css | 227 +++++++++--------- .../pages/Services/ServicesList.tsx | 29 +-- 2 files changed, 113 insertions(+), 143 deletions(-) diff --git a/client/src/components/pages/Services/Services.module.css b/client/src/components/pages/Services/Services.module.css index c1df102..19e8096 100644 --- a/client/src/components/pages/Services/Services.module.css +++ b/client/src/components/pages/Services/Services.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-5); display: flex; flex-direction: column; overflow: auto; @@ -11,39 +11,33 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .titleRow { display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; + line-height: var(--line-height-tight); } .headerActions { display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); } .refreshingIndicator { display: flex; align-items: center; -} - -.spinnerSmall { - width: 1rem; - height: 1rem; - border: 2px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -51,16 +45,16 @@ .autoRefreshControls { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.75rem; - background-color: var(--color-bg-hover); + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); } .autoRefreshLabel { - font-size: 0.8125rem; - font-weight: 500; + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); white-space: nowrap; } @@ -71,21 +65,16 @@ width: 2.5rem; height: 1.5rem; padding: 0; - background-color: var(--color-border-input); + background-color: var(--color-border); border: none; border-radius: 9999px; cursor: pointer; - transition: background-color 0.2s ease; + transition: background-color var(--duration-fast) ease; flex-shrink: 0; } -.togglePill:focus { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - .togglePill.toggleActive { - background-color: var(--color-success); + background-color: var(--color-healthy); } .toggleKnob { @@ -94,10 +83,10 @@ left: 2px; width: 1.25rem; height: 1.25rem; - background-color: var(--color-bg-card); + background-color: var(--color-surface); border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: transform 0.2s ease; + box-shadow: var(--shadow-sm); + transition: transform var(--duration-fast) ease; } .togglePill.toggleActive .toggleKnob { @@ -106,25 +95,26 @@ /* Interval dropdown */ .intervalSelect { - padding: 0.25rem 1.5rem 0.25rem 0.5rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-input); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-1) var(--space-5) var(--space-1) var(--space-2); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.25rem center; + background-position: right var(--space-1) center; background-repeat: no-repeat; background-size: 1.25rem 1.25rem; - transition: border-color 0.15s, opacity 0.15s; + transition: border-color var(--duration-fast) ease, opacity var(--duration-fast) ease; } -.intervalSelect:focus { +.intervalSelect:focus-visible { outline: none; border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .intervalSelect:disabled { @@ -136,48 +126,44 @@ .addButton { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease; } .addButton:hover { background-color: var(--color-accent-hover); } -.addButton:focus { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-secondary); + background-color: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); + color: var(--color-text); } /* Filters */ .filters { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } .searchWrapper { @@ -188,7 +174,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -197,47 +183,48 @@ .searchInput { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.5rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3) var(--space-2) 2.5rem; + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease; } -.searchInput:focus { +.searchInput:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .teamSelect { - padding: 0.5rem 2rem 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) 2rem var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; + background-position: right var(--space-2) center; background-repeat: no-repeat; background-size: 1.5rem 1.5rem; + transition: border-color var(--duration-fast) ease; } -.teamSelect:focus { +.teamSelect:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } /* Table */ .tableWrapper { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); overflow: hidden; } @@ -248,38 +235,38 @@ .table th { text-align: left; - padding: 0.75rem 1rem; - font-size: 0.75rem; - font-weight: 600; + padding: var(--space-3) var(--space-4); + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.04em; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); } .sortableHeader { cursor: pointer; user-select: none; - transition: color 0.15s; + transition: color var(--duration-fast) ease; } .sortableHeader:hover { - color: var(--color-text-primary); + color: var(--color-text); } .sortIndicator { - margin-left: 0.375rem; + margin-left: var(--space-1); font-size: 0.625rem; display: inline-block; width: 0.75rem; } .table td { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-text-primary); - border-bottom: 1px solid var(--color-border); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-text); + border-bottom: 1px solid var(--color-border-subtle); vertical-align: middle; } @@ -287,17 +274,23 @@ border-bottom: none; } +.table tbody tr { + transition: background-color var(--duration-fast) ease; +} + .table tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .serviceLink { color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); + transition: color var(--duration-fast) ease; } .serviceLink:hover { + color: var(--color-accent-hover); text-decoration: underline; } @@ -308,22 +301,22 @@ .depsCell { display: inline-flex; align-items: center; - gap: 0.375rem; + gap: var(--space-1); } .depsCount { - font-weight: 500; + font-weight: var(--font-medium); font-variant-numeric: tabular-nums; } .depsLabel { color: var(--color-text-muted); - font-size: 0.75rem; + font-size: var(--font-xs); } .timeCell { color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } /* States */ @@ -332,17 +325,13 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-5); color: var(--color-text-muted); } .spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -356,9 +345,9 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-5); + color: var(--color-critical); text-align: center; } @@ -366,13 +355,13 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-5); color: var(--color-text-muted); text-align: center; - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); } /* Service Detail Page */ diff --git a/client/src/components/pages/Services/ServicesList.tsx b/client/src/components/pages/Services/ServicesList.tsx index 296843a..d2afc35 100644 --- a/client/src/components/pages/Services/ServicesList.tsx +++ b/client/src/components/pages/Services/ServicesList.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Link } from 'react-router-dom'; +import { Search, Plus, Loader2 } from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { useServicesList, type SortColumn } from '../../../hooks/useServicesList'; import StatusBadge from '../../common/StatusBadge'; @@ -77,7 +78,7 @@ function ServicesList() { return (
-
+ Loading services...
@@ -104,7 +105,7 @@ function ServicesList() {

Services

{isRefreshing && (
-
+
)}
@@ -138,16 +139,7 @@ function ServicesList() { onClick={() => setIsAddModalOpen(true)} className={styles.addButton} > - - - + Add Service )} @@ -156,18 +148,7 @@ function ServicesList() {
- - - - + Date: Thu, 5 Mar 2026 21:37:43 -0700 Subject: [PATCH 12/21] DPS-74: Polish Teams list page with design tokens and Lucide icons --- .../components/pages/Teams/Teams.module.css | 244 +++++++++--------- .../src/components/pages/Teams/TeamsList.tsx | 27 +- 2 files changed, 130 insertions(+), 141 deletions(-) diff --git a/client/src/components/pages/Teams/Teams.module.css b/client/src/components/pages/Teams/Teams.module.css index da1f772..893f082 100644 --- a/client/src/components/pages/Teams/Teams.module.css +++ b/client/src/components/pages/Teams/Teams.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-5); display: flex; flex-direction: column; overflow: auto; @@ -11,62 +11,59 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; + line-height: var(--line-height-tight); } /* Buttons */ .addButton { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease; } .addButton:hover { background-color: var(--color-accent-hover); } -.addButton:focus { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-secondary); + background-color: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); + color: var(--color-text); } /* Filters */ .filters { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } .searchWrapper { @@ -77,7 +74,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -86,26 +83,26 @@ .searchInput { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.5rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3) var(--space-2) 2.5rem; + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease; } -.searchInput:focus { +.searchInput:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } /* Table */ .tableWrapper { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); overflow: hidden; } @@ -116,21 +113,21 @@ .table th { text-align: left; - padding: 0.75rem 1rem; - font-size: 0.75rem; - font-weight: 600; + padding: var(--space-3) var(--space-4); + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.04em; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); } .table td { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-text-primary); - border-bottom: 1px solid var(--color-border); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-text); + border-bottom: 1px solid var(--color-border-subtle); vertical-align: middle; } @@ -138,17 +135,23 @@ border-bottom: none; } +.table tbody tr { + transition: background-color var(--duration-fast) ease; +} + .table tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .teamLink { color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); + transition: color var(--duration-fast) ease; } .teamLink:hover { + color: var(--color-accent-hover); text-decoration: underline; } @@ -163,17 +166,17 @@ .countCell { display: inline-flex; align-items: center; - gap: 0.375rem; + gap: var(--space-1); } .countValue { - font-weight: 500; + font-weight: var(--font-medium); font-variant-numeric: tabular-nums; } .countLabel { color: var(--color-text-muted); - font-size: 0.75rem; + font-size: var(--font-xs); } /* States */ @@ -182,17 +185,13 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-5); color: var(--color-text-muted); } .spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -206,9 +205,9 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-5); + color: var(--color-critical); text-align: center; } @@ -216,13 +215,13 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-5); color: var(--color-text-muted); text-align: center; - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); } /* Team Detail Page */ @@ -264,29 +263,29 @@ .teamKey { font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; + font-size: var(--font-xs); padding: 0.0625rem 0.375rem; - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); color: var(--color-text-muted); } .teamDescription { color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); margin: 0; } .contactInfo { display: flex; flex-wrap: wrap; - gap: 0.5rem 1rem; - margin-top: 0.25rem; + gap: var(--space-2) var(--space-4); + margin-top: var(--space-1); } .contactItem { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); } @@ -357,25 +356,25 @@ /* Section */ .section { - margin-bottom: 2rem; + margin-bottom: var(--space-6); } .sectionHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; + margin-bottom: var(--space-4); } .sectionTitle { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } .sectionSubtitle { - font-size: 0.875rem; + font-size: var(--font-base); color: var(--color-text-muted); } @@ -391,14 +390,14 @@ .roleCell { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .roleBadge { display: inline-flex; padding: 0.125rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; } @@ -419,16 +418,16 @@ .memberActions { display: flex; - gap: 0.5rem; + gap: var(--space-2); } .smallButton { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; - border-radius: 0.25rem; + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease; } .roleButton { @@ -462,27 +461,34 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--color-border); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + transition: background-color var(--duration-fast) ease; } .serviceItem:last-child { border-bottom: none; } +.serviceItem:hover { + background-color: var(--color-surface-hover); +} + .serviceInfo { display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); } .serviceName { color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); + transition: color var(--duration-fast) ease; } .serviceName:hover { + color: var(--color-accent-hover); text-decoration: underline; } @@ -501,18 +507,18 @@ } .noItems { - padding: 2rem; + padding: var(--space-6); text-align: center; color: var(--color-text-muted); - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); } /* Add Member Form */ .addMemberForm { display: flex; - gap: 0.75rem; + gap: var(--space-3); align-items: flex-end; } @@ -522,38 +528,40 @@ .addMemberForm .label { display: block; - font-size: 0.75rem; - font-weight: 500; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); - margin-bottom: 0.25rem; + margin-bottom: var(--space-1); } .addMemberForm .select { width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease; } -.addMemberForm .select:focus { +.addMemberForm .select:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .addMemberButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-sm); cursor: pointer; white-space: nowrap; + transition: background-color var(--duration-fast) ease; } .addMemberButton:hover:not(:disabled) { @@ -591,7 +599,7 @@ /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .filters { @@ -605,7 +613,7 @@ .header { flex-direction: column; align-items: flex-start; - gap: 1rem; + gap: var(--space-4); } .overviewPanel { diff --git a/client/src/components/pages/Teams/TeamsList.tsx b/client/src/components/pages/Teams/TeamsList.tsx index f755b0b..4d6dff0 100644 --- a/client/src/components/pages/Teams/TeamsList.tsx +++ b/client/src/components/pages/Teams/TeamsList.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; +import { Search, Plus, Loader2 } from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { fetchTeams } from '../../../api/teams'; import type { TeamWithCounts } from '../../../types/team'; @@ -50,7 +51,7 @@ function TeamsList() { return (
-
+ Loading teams...
@@ -79,16 +80,7 @@ function TeamsList() { onClick={() => setIsAddModalOpen(true)} className={styles.addButton} > - - - + Add Team )} @@ -96,18 +88,7 @@ function TeamsList() {
- - - - + Date: Thu, 5 Mar 2026 21:43:55 -0700 Subject: [PATCH 13/21] DPS-74: Polish Wallboard page with design tokens and Lucide icons Replace hardcoded CSS values with design system tokens across Wallboard, DependencyDetailPanel, and ServiceDetailPanel. Replace inline SVGs with Lucide React icons (Settings, Loader2, X, ChevronRight, AlertCircle). Add consistent dot+label status badges, slim scrollbars, and proper transition tokens throughout. --- .../DependencyDetailPanel.module.css | 227 +++++++-------- .../pages/Wallboard/DependencyDetailPanel.tsx | 9 +- .../Wallboard/ServiceDetailPanel.module.css | 225 ++++++++------- .../pages/Wallboard/ServiceDetailPanel.tsx | 18 +- .../pages/Wallboard/Wallboard.module.css | 260 ++++++++++-------- .../components/pages/Wallboard/Wallboard.tsx | 9 +- 6 files changed, 403 insertions(+), 345 deletions(-) diff --git a/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css b/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css index a994d6c..8802ff0 100644 --- a/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css +++ b/client/src/components/pages/Wallboard/DependencyDetailPanel.module.css @@ -4,27 +4,29 @@ right: 0; bottom: 0; width: 400px; - background: var(--color-bg-card); + background: var(--color-surface); border-left: 1px solid var(--color-border); + box-shadow: var(--shadow-md); display: flex; flex-direction: column; overflow: hidden; - z-index: 10; + z-index: var(--z-dropdown); } .header { display: flex; align-items: center; justify-content: space-between; - padding: 16px 20px; + padding: var(--space-4) var(--space-5); border-bottom: 1px solid var(--color-border); } .title { margin: 0; - font-size: 18px; - font-weight: 600; - color: var(--color-text-primary); + font-size: var(--font-xl); + font-weight: var(--font-semibold); + color: var(--color-text); + line-height: var(--line-height-tight); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -41,72 +43,72 @@ background: transparent; color: var(--color-text-muted); cursor: pointer; - border-radius: 6px; - transition: all 0.15s; + border-radius: var(--radius-sm); + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; flex-shrink: 0; } .closeButton:hover { - background: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text); } .scrollContent { flex: 1; min-height: 0; overflow-y: auto; - scrollbar-width: none; + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; } .scrollContent::-webkit-scrollbar { - display: none; + width: 4px; +} + +.scrollContent::-webkit-scrollbar-track { + background: transparent; +} + +.scrollContent::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 2px; } .statusSection { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border-subtle); display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .statusBadge { display: inline-flex; align-items: center; - gap: 8px; - padding: 6px 12px; - border-radius: 20px; - font-size: 13px; - font-weight: 500; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--font-sm); + font-weight: var(--font-medium); } .statusBadge.healthy { - background: #ecfdf5; - color: #059669; -} - -[data-theme="dark"] .statusBadge.healthy { - background: #064e3b; - color: #34d399; + background: color-mix(in srgb, var(--color-healthy) 12%, transparent); + color: var(--color-healthy); } .statusBadge.warning { - background: #fffbeb; - color: #d97706; -} - -[data-theme="dark"] .statusBadge.warning { - background: #451a03; - color: #fbbf24; + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + color: var(--color-warning); } .statusBadge.critical { - background: var(--color-error-bg); - color: var(--color-error); + background: color-mix(in srgb, var(--color-critical) 12%, transparent); + color: var(--color-critical); } .statusBadge.unknown { - background: var(--color-bg-hover); + background: var(--color-surface-hover); color: var(--color-text-muted); } @@ -118,48 +120,50 @@ } .section { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border-subtle); } .sectionTitle { - margin: 0 0 4px 0; - font-size: 12px; - font-weight: 600; + margin: 0 0 var(--space-2) 0; + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; color: var(--color-text-muted); } .detailsGrid { display: flex; flex-direction: column; - gap: 12px; + gap: var(--space-3); } .detailItem { display: flex; flex-direction: column; - gap: 4px; + gap: var(--space-1); } .detailLabel { - font-size: 12px; + font-size: var(--font-xs); color: var(--color-text-muted); } .detailValue { - font-size: 14px; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); overflow-wrap: break-word; + font-variant-numeric: tabular-nums; } .errorMessage { - font-size: 13px; - color: var(--color-error); - padding: 8px 10px; - background: var(--color-error-bg); - border-radius: 6px; + margin-top: var(--space-3); + font-size: var(--font-sm); + color: var(--color-critical); + padding: var(--space-2) var(--space-3); + background: color-mix(in srgb, var(--color-critical) 8%, transparent); + border-radius: var(--radius-sm); word-break: break-word; } @@ -169,7 +173,7 @@ padding: 0; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); max-height: 200px; overflow-y: auto; } @@ -177,10 +181,11 @@ .reporterItem { display: flex; align-items: center; - gap: 8px; - padding: 8px 10px; - background: var(--color-bg-hover); - border-radius: 6px; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--color-surface-hover); + border-radius: var(--radius-sm); + transition: background-color var(--duration-fast) ease; } .healthDot { @@ -191,15 +196,15 @@ } .healthDot.healthy { - background: var(--color-success); + background: var(--color-healthy); } .healthDot.critical { - background: var(--color-error); + background: var(--color-critical); } .healthDot.unknown { - background: var(--color-text-muted); + background: var(--color-unknown); } .reporterInfo { @@ -211,12 +216,13 @@ } .reporterServiceLink { - font-size: 13px; + font-size: var(--font-sm); color: var(--color-accent); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: color var(--duration-fast) ease; } .reporterServiceLink:hover { @@ -224,31 +230,33 @@ } .reporterTeam { - font-size: 11px; + font-size: var(--font-xs); color: var(--color-text-muted); } .latencyLabel { margin-left: auto; - font-size: 11px; + font-size: var(--font-xs); color: var(--color-text-muted); - background: var(--color-bg-card); - padding: 2px 6px; - border-radius: 4px; + background: var(--color-surface); + padding: 2px var(--space-2); + border-radius: var(--radius-sm); flex-shrink: 0; + font-variant-numeric: tabular-nums; } .linkedService { display: flex; align-items: center; - gap: 6px; - font-size: 13px; + gap: var(--space-2); + font-size: var(--font-sm); color: var(--color-text-secondary); } .linkedServiceLink { color: var(--color-accent); text-decoration: none; + transition: color var(--duration-fast) ease; } .linkedServiceLink:hover { @@ -256,43 +264,43 @@ } .chartSection { - padding: 16px 20px; - border-bottom: 1px solid var(--color-border); + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--color-border-subtle); } .chartTitle { - margin: 0 0 12px 0; - font-size: 12px; - font-weight: 600; + margin: 0 0 var(--space-3) 0; + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.04em; color: var(--color-text-muted); } .actions { - padding: 16px 20px; + padding: var(--space-4) var(--space-5); margin-top: auto; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .actionLink { display: flex; align-items: center; justify-content: center; - gap: 8px; + gap: var(--space-2); width: 100%; - padding: 10px 16px; + padding: var(--space-2) var(--space-4); background: var(--color-accent); - color: var(--color-text-inverse); + color: #fff; border: none; - border-radius: 8px; - font-size: 14px; - font-weight: 500; + border-radius: var(--radius-sm); + font-size: var(--font-base); + font-weight: var(--font-medium); text-decoration: none; cursor: pointer; - transition: background 0.15s; + transition: background-color var(--duration-fast) ease; } .actionLink:hover { @@ -301,37 +309,38 @@ .secondaryLink { composes: actionLink; - background: var(--color-bg-hover); - color: var(--color-text-primary); + background: transparent; + color: var(--color-text-secondary); border: 1px solid var(--color-border); } .secondaryLink:hover { - background: var(--color-bg-active); + background: var(--color-surface-hover); + color: var(--color-text); } .typeBadge { display: inline-block; - padding: 2px 8px; - border-radius: 9999px; - font-size: 12px; - font-weight: 500; + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--font-xs); + font-weight: var(--font-medium); text-transform: capitalize; - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text-secondary); } .overrideBadge { display: inline-block; - margin-left: 6px; - padding: 1px 6px; + margin-left: var(--space-2); + padding: 1px var(--space-2); font-size: 10px; - font-weight: 600; + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.3px; - border-radius: 4px; + letter-spacing: 0.03em; + border-radius: var(--radius-sm); background: var(--color-accent); - color: var(--color-text-inverse); + color: #fff; vertical-align: middle; } @@ -340,21 +349,21 @@ padding: 0; display: flex; flex-direction: column; - gap: 8px; + gap: var(--space-2); } .contactItem { display: flex; align-items: baseline; - gap: 8px; - padding: 6px 10px; - background: var(--color-bg-hover); - border-radius: 6px; + gap: var(--space-2); + padding: var(--space-1) var(--space-3); + background: var(--color-surface-hover); + border-radius: var(--radius-sm); } .contactKey { - font-size: 12px; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); text-transform: capitalize; flex-shrink: 0; @@ -366,8 +375,8 @@ } .contactValue { - font-size: 13px; - color: var(--color-text-primary); + font-size: var(--font-sm); + color: var(--color-text); margin: 0; overflow-wrap: break-word; min-width: 0; @@ -380,6 +389,6 @@ top: 0; right: 0; bottom: 0; - z-index: 200; + z-index: var(--z-modal); } } diff --git a/client/src/components/pages/Wallboard/DependencyDetailPanel.tsx b/client/src/components/pages/Wallboard/DependencyDetailPanel.tsx index 7f53314..ea388d1 100644 --- a/client/src/components/pages/Wallboard/DependencyDetailPanel.tsx +++ b/client/src/components/pages/Wallboard/DependencyDetailPanel.tsx @@ -1,5 +1,6 @@ import { memo } from 'react'; import { Link } from 'react-router-dom'; +import { X, ChevronRight } from 'lucide-react'; import { formatRelativeTime } from '../../../utils/formatting'; import { LatencyChart } from '../../Charts/LatencyChart'; import { HealthTimeline } from '../../Charts/HealthTimeline'; @@ -83,9 +84,7 @@ function DependencyDetailPanelComponent({ dependency, onClose }: DependencyDetai

{dependency.canonical_name}

@@ -215,9 +214,7 @@ function DependencyDetailPanelComponent({ dependency, onClose }: DependencyDetai className={styles.actionLink} > View in Graph - - - + {dependency.linked_service && (

Loading...

@@ -89,9 +88,7 @@ function ServiceDetailPanelComponent({ serviceId, onClose }: ServiceDetailPanelP

Error

{error || 'Service not found'}
@@ -120,10 +117,7 @@ function ServiceDetailPanelComponent({ serviceId, onClose }: ServiceDetailPanelP
{service.is_external !== 1 && service.last_poll_success === 0 && (
- - - - + Poll failed{service.last_poll_error ? `: ${service.last_poll_error}` : ''}
)} @@ -267,9 +261,7 @@ function ServiceDetailPanelComponent({ serviceId, onClose }: ServiceDetailPanelP
View Full Details - - - +
diff --git a/client/src/components/pages/Wallboard/Wallboard.module.css b/client/src/components/pages/Wallboard/Wallboard.module.css index 5ee079b..8d7d0da 100644 --- a/client/src/components/pages/Wallboard/Wallboard.module.css +++ b/client/src/components/pages/Wallboard/Wallboard.module.css @@ -7,7 +7,7 @@ } .container { - padding: 1.5rem; + padding: var(--space-5); flex: 1; overflow-y: auto; } @@ -16,34 +16,28 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; - gap: 1rem; + margin-bottom: var(--space-5); + gap: var(--space-4); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); + line-height: var(--line-height-tight); } /* Header right section */ .headerRight { display: flex; align-items: center; - gap: 8px; + gap: var(--space-2); } .refreshingIndicator { display: flex; align-items: center; -} - -.spinnerSmall { - width: 14px; - height: 14px; - border: 2px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -56,33 +50,33 @@ display: flex; align-items: center; justify-content: center; - padding: 6px; - background: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 6px; + padding: var(--space-2); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - color: var(--color-text-primary); - transition: all 0.15s; + color: var(--color-text-secondary); + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .settingsButton:hover { - background: var(--color-bg-hover); - border-color: var(--color-text-muted); + background: var(--color-surface-hover); + color: var(--color-text); } .settingsMenu { position: absolute; - top: calc(100% + 8px); + top: calc(100% + var(--space-2)); right: 0; - z-index: 50; - padding: 12px; - background: var(--color-bg-card); + z-index: var(--z-dropdown); + padding: var(--space-3); + background: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 8px; + border-radius: var(--radius-md); box-shadow: var(--shadow-md); display: flex; flex-direction: column; - gap: 12px; + gap: var(--space-3); min-width: 240px; } @@ -90,27 +84,29 @@ display: flex; align-items: center; justify-content: space-between; - gap: 12px; + gap: var(--space-3); } .settingsMenuLabel { - font-size: 13px; - font-weight: 500; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.03em; white-space: nowrap; } .settingsMenuDivider { height: 1px; - background: var(--color-border); - margin: 0 -4px; + background: var(--color-border-subtle); + margin: 0 calc(var(--space-1) * -1); } .filterToggle { display: flex; align-items: center; - gap: 0.5rem; - font-size: 0.875rem; + gap: var(--space-2); + font-size: var(--font-base); color: var(--color-text-secondary); cursor: pointer; user-select: none; @@ -123,7 +119,7 @@ .pollingControls { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .togglePill { @@ -131,21 +127,21 @@ width: 2.5rem; height: 1.5rem; padding: 0; - background-color: var(--color-border-input); + background-color: var(--color-border); border: none; border-radius: 1rem; cursor: pointer; - transition: background-color 0.2s; + transition: background-color var(--duration-normal) ease; flex-shrink: 0; } -.togglePill:focus { +.togglePill:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; } .togglePill.toggleActive { - background-color: var(--color-success); + background-color: var(--color-healthy); } .toggleKnob { @@ -154,10 +150,10 @@ left: 0.125rem; width: 1.25rem; height: 1.25rem; - background-color: var(--color-bg-card); + background-color: var(--color-surface); border-radius: 50%; - transition: transform 0.2s; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + transition: transform var(--duration-normal) ease; + box-shadow: var(--shadow-sm); } .togglePill.toggleActive .toggleKnob { @@ -165,13 +161,20 @@ } .intervalSelect { - padding: 0.375rem 0.5rem; - font-size: 0.8rem; + font-size: var(--font-sm); + padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border); - border-radius: 4px; - background-color: var(--color-bg); + border-radius: var(--radius-sm); + background: var(--color-surface); color: var(--color-text); cursor: pointer; + transition: border-color var(--duration-fast) ease; +} + +.intervalSelect:focus-visible { + outline: none; + border-color: var(--color-accent); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .intervalSelect:disabled { @@ -180,57 +183,57 @@ } .teamSelect { - padding: 8px 12px; - border: 1px solid var(--color-border-input); - border-radius: 6px; - font-size: 14px; - background: var(--color-bg-input); - color: var(--color-text-primary); + font-size: var(--font-base); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface); + color: var(--color-text); min-width: 150px; cursor: pointer; + transition: border-color var(--duration-fast) ease; } -.teamSelect:focus { +.teamSelect:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } /* Grid */ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1rem; + gap: var(--space-4); } /* Card */ .card { - background-color: var(--color-bg-active); - border: 1px solid var(--color-border-light); - border-radius: 8px; - padding: 1.25rem; - border-left: 4px solid transparent; - transition: box-shadow 0.2s, border-color 0.2s, background-color 0.2s; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + border-left: 3px solid transparent; + transition: background-color var(--duration-fast) ease, border-color var(--duration-fast) ease; cursor: pointer; outline: none; } .card:hover { - box-shadow: var(--shadow-md); - background-color: var(--color-bg-hover); + background: var(--color-surface-hover); } .card:focus-visible { - box-shadow: 0 0 0 2px var(--color-primary); + box-shadow: 0 0 0 2px var(--color-accent); } .cardSelected { - box-shadow: 0 0 0 2px var(--color-primary); - background-color: var(--color-bg-hover); + box-shadow: 0 0 0 2px var(--color-accent); + background: var(--color-surface-hover); } .cardHealthy { - border-left-color: var(--color-success); + border-left-color: var(--color-healthy); } .cardWarning { @@ -238,46 +241,47 @@ } .cardCritical { - border-left-color: var(--color-error); + border-left-color: var(--color-critical); } .cardUnknown { - border-left-color: var(--color-text-muted); + border-left-color: var(--color-unknown); } .cardSkipped { - border-left-color: var(--color-border-input); + border-left-color: var(--color-border); border-left-style: dashed; opacity: 0.75; } .cardName { - font-size: 1rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--color-text); display: flex; align-items: center; - gap: 0.5rem; - margin-bottom: 0.75rem; + gap: var(--space-2); + margin-bottom: var(--space-3); + line-height: var(--line-height-tight); } .typeBadge { display: inline-block; - padding: 0.125rem 0.5rem; - border-radius: 9999px; - font-size: 0.6875rem; - font-weight: 500; + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--font-xs); + font-weight: var(--font-medium); text-transform: capitalize; - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text-secondary); flex-shrink: 0; } .cardMeta { display: flex; flex-direction: column; - gap: 0.375rem; - font-size: 0.8125rem; + gap: var(--space-1); + font-size: var(--font-sm); color: var(--color-text-secondary); } @@ -287,6 +291,10 @@ align-items: center; } +.cardMetaRow span:last-child { + font-variant-numeric: tabular-nums; +} + .reporterNames { text-align: right; max-width: 60%; @@ -301,17 +309,26 @@ /* Status badge inline */ .statusBadge { - display: inline-block; - padding: 0.125rem 0.5rem; - border-radius: 12px; - font-size: 0.75rem; - font-weight: 600; + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: 2px var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: capitalize; } +.statusDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + .statusHealthy { - background-color: color-mix(in srgb, var(--color-success) 15%, transparent); - color: var(--color-success); + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); } .statusWarning { @@ -320,30 +337,30 @@ } .statusCritical { - background-color: color-mix(in srgb, var(--color-error) 15%, transparent); - color: var(--color-error); + background-color: color-mix(in srgb, var(--color-critical) 15%, transparent); + color: var(--color-critical); } .statusUnknown { - background-color: color-mix(in srgb, var(--color-text-muted) 15%, transparent); - color: var(--color-text-muted); + background-color: color-mix(in srgb, var(--color-unknown) 15%, transparent); + color: var(--color-unknown); } .statusSkipped { - background-color: color-mix(in srgb, var(--color-border-input) 20%, transparent); + background-color: color-mix(in srgb, var(--color-border) 20%, transparent); color: var(--color-text-muted); font-style: italic; } /* Error row */ .errorRow { - margin-top: 0.5rem; - padding: 0.375rem 0.5rem; - background-color: color-mix(in srgb, var(--color-error) 8%, transparent); - border-radius: 4px; - font-size: 0.75rem; - color: var(--color-error); - line-height: 1.4; + margin-top: var(--space-2); + padding: var(--space-1) var(--space-2); + background-color: color-mix(in srgb, var(--color-critical) 8%, transparent); + border-radius: var(--radius-sm); + font-size: var(--font-xs); + color: var(--color-critical); + line-height: var(--line-height-normal); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -354,16 +371,17 @@ display: flex; align-items: center; justify-content: center; - gap: 0.75rem; - padding: 3rem; + gap: var(--space-3); + padding: var(--space-7); color: var(--color-text-muted); + font-size: var(--font-sm); } .spinner { width: 24px; height: 24px; border: 3px solid var(--color-border); - border-top-color: var(--color-primary); + border-top-color: var(--color-accent); border-radius: 50%; animation: spin 0.8s linear infinite; } @@ -373,20 +391,40 @@ } .error { - padding: 2rem; + padding: var(--space-6); text-align: center; - color: var(--color-error); + color: var(--color-critical); + font-size: var(--font-base); +} + +.error button { + margin-top: var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border); + cursor: pointer; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; +} + +.error button:hover { + background: var(--color-surface-hover); + color: var(--color-text); } .emptyState { - padding: 3rem; + padding: var(--space-7); text-align: center; color: var(--color-text-muted); + font-size: var(--font-base); } @media (max-width: 768px) { .container { - padding: 1rem; + padding: var(--space-4); } .header { diff --git a/client/src/components/pages/Wallboard/Wallboard.tsx b/client/src/components/pages/Wallboard/Wallboard.tsx index ee3aa76..0e77ffb 100644 --- a/client/src/components/pages/Wallboard/Wallboard.tsx +++ b/client/src/components/pages/Wallboard/Wallboard.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { Settings, Loader2 } from 'lucide-react'; import { fetchWallboardData } from '../../../api/wallboard'; import { formatRelativeTime } from '../../../utils/formatting'; import { usePolling, INTERVAL_OPTIONS } from '../../../hooks/usePolling'; @@ -161,7 +162,7 @@ function Wallboard() {
{isRefreshing && (
-
+
)}
@@ -172,10 +173,7 @@ function Wallboard() { aria-label="Wallboard settings" aria-expanded={settingsOpen} > - - - - + {settingsOpen && (
@@ -272,6 +270,7 @@ function Wallboard() {
Status + {dep.health_status}
From a89aeb9a45b08f90d4fbc6741203dc82a4c56b6f Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 21:48:35 -0700 Subject: [PATCH 14/21] DPS-74: Polish Catalog page with design tokens and Lucide icons --- .../pages/Catalog/ExternalDependencies.tsx | 39 +-- .../pages/Catalog/ServiceCatalog.module.css | 291 +++++++++--------- .../pages/Catalog/ServiceCatalog.tsx | 38 +-- 3 files changed, 162 insertions(+), 206 deletions(-) diff --git a/client/src/components/pages/Catalog/ExternalDependencies.tsx b/client/src/components/pages/Catalog/ExternalDependencies.tsx index 7da4b9f..3144ae3 100644 --- a/client/src/components/pages/Catalog/ExternalDependencies.tsx +++ b/client/src/components/pages/Catalog/ExternalDependencies.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, Copy, Check, Loader2 } from 'lucide-react'; import { useExternalDependencies } from '../../../hooks/useExternalDependencies'; import styles from './ServiceCatalog.module.css'; @@ -35,7 +36,7 @@ function ExternalDependencies() { if (isLoading) { return (
-
+ Loading external dependencies...
); @@ -56,18 +57,7 @@ function ExternalDependencies() { <>
- - - - + {copiedName === entry.canonical_name ? ( - - - + ) : ( - - - - + )}
diff --git a/client/src/components/pages/Catalog/ServiceCatalog.module.css b/client/src/components/pages/Catalog/ServiceCatalog.module.css index f982792..87f26da 100644 --- a/client/src/components/pages/Catalog/ServiceCatalog.module.css +++ b/client/src/components/pages/Catalog/ServiceCatalog.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-5); display: flex; flex-direction: column; overflow: auto; @@ -11,14 +11,15 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; + line-height: var(--line-height-tight); } /* Tab Bar */ @@ -26,35 +27,36 @@ display: flex; gap: 0; border-bottom: 1px solid var(--color-border); - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .tab { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast) ease, border-color var(--duration-fast) ease; } .tab:hover { - color: var(--color-text-primary); + color: var(--color-text-secondary); } .tabActive { - color: var(--color-accent); + color: var(--color-text); + font-weight: var(--font-semibold); border-bottom-color: var(--color-accent); } /* Filters */ .filters { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } .searchWrapper { @@ -65,7 +67,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -74,78 +76,79 @@ .searchInput { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.5rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3) var(--space-2) 2.5rem; + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease; } -.searchInput:focus { +.searchInput:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .teamSelect { - padding: 0.5rem 2rem 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) 2rem var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; + background-position: right var(--space-2) center; background-repeat: no-repeat; background-size: 1.5rem 1.5rem; + transition: border-color var(--duration-fast) ease; } -.teamSelect:focus { +.teamSelect:focus-visible { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } /* Team Sections */ .teamSections { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-3); } .teamSection { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); overflow: hidden; } .teamHeader { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 100%; - padding: 0.75rem 1rem; + padding: var(--space-3) var(--space-4); background: none; border: none; cursor: pointer; - font-size: 0.875rem; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease; } .teamHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .chevron { flex-shrink: 0; - transition: transform 0.2s ease; + transition: transform var(--duration-normal) ease; color: var(--color-text-muted); } @@ -154,47 +157,48 @@ } .teamName { - font-weight: 600; - color: var(--color-text-heading); + font-weight: var(--font-semibold); + color: var(--color-text); } .teamKeyBadge { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; - padding: 0.0625rem 0.375rem; - background-color: var(--color-bg-hover); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); + padding: 1px var(--space-2); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); color: var(--color-text-muted); } .serviceCount { margin-left: auto; - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); - font-weight: 400; + font-weight: var(--font-normal); } /* Service Grid */ .serviceGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 0.75rem; - padding: 0 1rem 1rem; + gap: var(--space-3); + padding: 0 var(--space-4) var(--space-4); } .serviceCard { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; - padding: 0.75rem; + border-radius: var(--radius-md); + padding: var(--space-3); display: flex; flex-direction: column; - gap: 0.375rem; - transition: border-color 0.15s; + gap: var(--space-1); + transition: border-color var(--duration-fast) ease, background-color var(--duration-fast) ease; } .serviceCard:hover { + background-color: var(--color-surface-hover); border-color: var(--color-text-muted); } @@ -202,13 +206,13 @@ display: flex; align-items: center; justify-content: space-between; - gap: 0.5rem; + gap: var(--space-2); } .cardName { - font-weight: 500; - font-size: 0.875rem; - color: var(--color-text-heading); + font-weight: var(--font-medium); + font-size: var(--font-base); + color: var(--color-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -217,17 +221,17 @@ .cardKey { display: flex; align-items: center; - gap: 0.375rem; + gap: var(--space-1); min-height: 1.5rem; } .manifestKeyCode { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; - padding: 0.125rem 0.375rem; - background-color: var(--color-bg-hover); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); + padding: 1px var(--space-2); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -241,57 +245,57 @@ height: 22px; padding: 0; border: 1px solid var(--color-border); - background: var(--color-bg-card); + background: var(--color-surface); color: var(--color-text-muted); cursor: pointer; - border-radius: 4px; - transition: all 0.15s; + border-radius: var(--radius-sm); + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease, border-color var(--duration-fast) ease; flex-shrink: 0; } .copyButton:hover { - background: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text); border-color: var(--color-text-muted); } .copyButtonCopied { - color: var(--color-success); - border-color: var(--color-success); + color: var(--color-healthy); + border-color: var(--color-healthy); } .cardDescription { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); - line-height: 1.4; + line-height: var(--line-height-normal); } .noKey { color: var(--color-text-muted); font-style: italic; - font-size: 0.75rem; + font-size: var(--font-xs); } /* Status */ .statusBadge { display: inline-flex; align-items: center; - gap: 0.25rem; - padding: 0.0625rem 0.375rem; - font-size: 0.6875rem; - font-weight: 500; + gap: var(--space-1); + padding: 1px var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; flex-shrink: 0; } .statusActive { - color: var(--color-success); - background-color: color-mix(in srgb, var(--color-success) 10%, transparent); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .statusInactive { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .statusDot { @@ -302,7 +306,7 @@ } .statusDotActive { - background-color: var(--color-success); + background-color: var(--color-healthy); } .statusDotInactive { @@ -315,17 +319,13 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-5); color: var(--color-text-muted); } .spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -339,69 +339,72 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-5); + color: var(--color-critical); text-align: center; } .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-secondary); + background-color: transparent; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); + color: var(--color-text); } .emptyState { display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-5); color: var(--color-text-muted); text-align: center; - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-md); } /* External Dependencies Table */ .tableWrapper { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-md); overflow: hidden; } .depTable { width: 100%; border-collapse: collapse; - font-size: 0.875rem; + font-size: var(--font-base); } .depTable th { text-align: left; - padding: 0.5rem 0.75rem; - font-weight: 600; - font-size: 0.8125rem; - color: var(--color-text-heading); + padding: var(--space-3) var(--space-4); + font-size: var(--font-xs); + font-weight: var(--font-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); - background-color: var(--color-bg-hover); } .depTable td { - padding: 0.625rem 0.75rem; - border-bottom: 1px solid var(--color-border); - color: var(--color-text-primary); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border-subtle); + color: var(--color-text); vertical-align: top; } @@ -409,63 +412,67 @@ border-bottom: none; } +.depTable tbody tr { + transition: background-color var(--duration-fast) ease; +} + .depTable tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .canonicalCell { display: flex; align-items: center; - gap: 0.375rem; + gap: var(--space-1); } .canonicalCode { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-heading); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); } .noDescription { color: var(--color-text-muted); font-style: italic; - font-size: 0.8125rem; + font-size: var(--font-sm); } .teamChips { display: flex; flex-wrap: wrap; - gap: 0.25rem; + gap: var(--space-1); } .teamChip { display: inline-block; - padding: 0.0625rem 0.375rem; - font-size: 0.75rem; - background-color: var(--color-bg-hover); + padding: 1px var(--space-2); + font-size: var(--font-xs); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); } .aliasBadges { display: flex; flex-wrap: wrap; - gap: 0.25rem; + gap: var(--space-1); } .aliasCode { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; - padding: 0.0625rem 0.375rem; - background-color: var(--color-bg-hover); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); + padding: 1px var(--space-2); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); } /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .filters { @@ -479,7 +486,7 @@ .header { flex-direction: column; align-items: flex-start; - gap: 1rem; + gap: var(--space-4); } .serviceGrid { diff --git a/client/src/components/pages/Catalog/ServiceCatalog.tsx b/client/src/components/pages/Catalog/ServiceCatalog.tsx index 04f6d31..d63b93c 100644 --- a/client/src/components/pages/Catalog/ServiceCatalog.tsx +++ b/client/src/components/pages/Catalog/ServiceCatalog.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, ChevronRight, Copy, Check, Loader2 } from 'lucide-react'; import { fetchServiceCatalog, fetchTeams } from '../../../api/services'; import type { CatalogEntry, TeamWithCounts } from '../../../types/service'; import ExternalDependencies from './ExternalDependencies'; @@ -125,7 +126,7 @@ function ServiceCatalog() { return (
-
+ Loading service catalog...
@@ -172,18 +173,7 @@ function ServiceCatalog() { <>
- - - - + toggleTeam(group.teamId)} aria-expanded={!isCollapsed} > - - - + /> {group.teamName} {group.teamKey && ( {group.teamKey} @@ -280,14 +263,9 @@ function ServiceCatalog() { aria-label={`Copy ${namespacedKey}`} > {copiedId === entry.id ? ( - - - + ) : ( - - - - + )}
From 95faea4ec44c8fd09400249e4913421d42ebe487 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 22:00:19 -0700 Subject: [PATCH 15/21] DPS-74: Polish Admin, Associations, and Manifest pages with design tokens and Lucide icons --- .../components/pages/Admin/Admin.module.css | 308 ++++----- .../pages/Admin/AdminSettings.module.css | 188 +++-- .../components/pages/Admin/AdminSettings.tsx | 47 +- .../pages/Admin/AlertMutesAdmin.module.css | 75 +- .../pages/Admin/ManifestAdmin.module.css | 188 +++-- .../components/pages/Admin/ManifestAdmin.tsx | 16 +- .../components/pages/Admin/UserManagement.tsx | 16 +- .../Associations/AliasesManager.module.css | 84 +-- .../pages/Associations/AliasesManager.tsx | 5 +- .../Associations/AssociationForm.module.css | 66 +- .../Associations/AssociationsPage.module.css | 30 +- .../ExternalServicesManager.module.css | 128 ++-- .../Associations/ExternalServicesManager.tsx | 9 +- .../ManageAssociations.module.css | 300 ++++---- .../pages/Associations/ManageAssociations.tsx | 34 +- .../pages/Manifest/DriftReview.module.css | 252 +++---- .../pages/Manifest/ManifestPage.module.css | 648 +++++++++--------- .../pages/Manifest/ManifestPage.tsx | 14 +- .../pages/Manifest/ManifestSyncResult.tsx | 19 +- .../pages/Manifest/ServiceKeyLookup.tsx | 40 +- 20 files changed, 1144 insertions(+), 1323 deletions(-) diff --git a/client/src/components/pages/Admin/Admin.module.css b/client/src/components/pages/Admin/Admin.module.css index 7431d67..8ff28a2 100644 --- a/client/src/components/pages/Admin/Admin.module.css +++ b/client/src/components/pages/Admin/Admin.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; @@ -11,21 +11,21 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } /* Filters */ .filters { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-4); + margin-bottom: var(--space-5); } .searchWrapper { @@ -36,7 +36,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -45,32 +45,32 @@ .searchInput { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.5rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3) var(--space-2) 2.5rem; + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .searchInput:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .statusSelect { - padding: 0.5rem 2rem 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) 2rem var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; + background-position: right var(--space-2) center; background-repeat: no-repeat; background-size: 1.5rem 1.5rem; } @@ -78,7 +78,7 @@ .statusSelect:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } /* Action Error */ @@ -86,23 +86,23 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .dismissButton { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; - color: var(--color-error); + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); + color: var(--color-critical); background: none; border: 1px solid var(--color-error-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; } @@ -112,9 +112,9 @@ /* Table */ .tableWrapper { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-lg); overflow: hidden; } @@ -125,20 +125,20 @@ .table th { text-align: left; - padding: 0.75rem 1rem; - font-size: 0.75rem; - font-weight: 600; + padding: var(--space-3) var(--space-4); + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); } .table td { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-text-primary); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-text); border-bottom: 1px solid var(--color-border); vertical-align: middle; } @@ -148,7 +148,7 @@ } .table tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .inactiveRow { @@ -156,7 +156,7 @@ } .nameCell { - font-weight: 500; + font-weight: var(--font-medium); } .emailCell { @@ -167,101 +167,76 @@ .roleBadge, .statusBadge { display: inline-flex; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; } .roleAdmin { - color: #7c3aed; - background-color: #ede9fe; -} - -[data-theme="dark"] .roleAdmin { - color: #c4b5fd; - background-color: #3b2e5a; + color: var(--color-accent); + background-color: color-mix(in srgb, var(--color-accent) 10%, transparent); } .roleUser { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .statusActive { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .statusActive { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .statusInactive { - color: #991b1b; - background-color: #fee2e2; -} - -[data-theme="dark"] .statusInactive { - color: #fca5a5; - background-color: #450a0a; + color: var(--color-critical); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); } /* Actions */ .actions { display: flex; - gap: 0.5rem; + gap: var(--space-2); } .actionButton { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; - border-radius: 0.25rem; + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); + border-radius: var(--radius-sm); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .roleButton { - color: var(--color-text-primary); - background-color: var(--color-bg-hover); - border: 1px solid var(--color-border-input); + color: var(--color-text); + background-color: var(--color-surface-hover); + border: 1px solid var(--color-border); } .roleButton:hover:not(:disabled) { - background-color: var(--color-bg-active); + background-color: var(--color-surface-hover); } .deactivateButton { - color: var(--color-error); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); } .deactivateButton:hover:not(:disabled) { background-color: var(--color-error-bg); - border-color: var(--color-error); + border-color: var(--color-critical); } .reactivateButton { - color: #166534; - background-color: #f0fdf4; - border: 1px solid #bbf7d0; -} - -[data-theme="dark"] .reactivateButton { - color: #86efac; - background-color: #14532d; - border-color: #22c55e; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); } .reactivateButton:hover:not(:disabled) { - background-color: #dcfce7; -} - -[data-theme="dark"] .reactivateButton:hover:not(:disabled) { - background-color: #166534; + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); } .actionButton:disabled { @@ -275,17 +250,13 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-6); color: var(--color-text-muted); } .spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -299,39 +270,39 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-6); + color: var(--color-critical); text-align: center; } .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .emptyState { display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-6); color: var(--color-text-muted); text-align: center; - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); } /* Create Button */ @@ -339,8 +310,8 @@ color: #fff; background-color: var(--color-accent); border: 1px solid var(--color-accent); - padding: 0.5rem 1rem; - font-size: 0.875rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); } .createButton:hover:not(:disabled) { @@ -352,94 +323,83 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: #166534; - background-color: #f0fdf4; - border: 1px solid #bbf7d0; - border-radius: 0.375rem; -} - -[data-theme="dark"] .successMessage { - color: #86efac; - background-color: #14532d; - border-color: #22c55e; + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); + border-radius: var(--radius-md); } .successMessage .dismissButton { - color: #166534; - border-color: #bbf7d0; -} - -[data-theme="dark"] .successMessage .dismissButton { - color: #86efac; - border-color: #22c55e; + color: var(--color-healthy); + border-color: color-mix(in srgb, var(--color-healthy) 30%, transparent); } /* Form Card */ .formCard { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.5rem; - margin-bottom: 1.5rem; + border-radius: var(--radius-lg); + padding: var(--space-5); + margin-bottom: var(--space-5); } .formTitle { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text-heading); - margin: 0 0 1rem 0; + font-size: var(--font-xl); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0 0 var(--space-4) 0; } .form { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-4); } .formField { display: flex; flex-direction: column; - gap: 0.25rem; + gap: var(--space-1); } .formLabel { - font-size: 0.8125rem; - font-weight: 500; + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); } .formInput { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .formInput:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .formActions { display: flex; - gap: 0.5rem; - margin-top: 0.5rem; + gap: var(--space-2); + margin-top: var(--space-2); } .formError { - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; - color: var(--color-error); + padding: var(--space-2) var(--space-3); + font-size: var(--font-sm); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); } /* Modal Overlay */ @@ -454,19 +414,19 @@ } .modalContent { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.5rem; + border-radius: var(--radius-lg); + padding: var(--space-5); width: 100%; max-width: 28rem; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); + box-shadow: var(--shadow-lg); } /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .filters { @@ -479,6 +439,6 @@ .actions { flex-direction: column; - gap: 0.25rem; + gap: var(--space-1); } } diff --git a/client/src/components/pages/Admin/AdminSettings.module.css b/client/src/components/pages/Admin/AdminSettings.module.css index e0cb8ea..b0a133e 100644 --- a/client/src/components/pages/Admin/AdminSettings.module.css +++ b/client/src/components/pages/Admin/AdminSettings.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; @@ -11,13 +11,13 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } @@ -26,42 +26,36 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: #166534; - background-color: #dcfce7; - border: 1px solid #bbf7d0; - border-radius: 0.375rem; -} - -[data-theme="dark"] .successBanner { - color: #86efac; - background-color: #14532d; - border-color: #22c55e; + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); + border-radius: var(--radius-md); } .errorBanner { display: flex; align-items: center; justify-content: space-between; - padding: 0.75rem 1rem; - margin-bottom: 1rem; - font-size: 0.875rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + font-size: var(--font-base); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .dismissButton { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); color: inherit; background: none; border: 1px solid currentColor; - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; opacity: 0.7; } @@ -74,13 +68,13 @@ .sections { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-4); } .section { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-lg); overflow: hidden; } @@ -89,22 +83,22 @@ justify-content: space-between; align-items: center; width: 100%; - padding: 1rem 1.25rem; + padding: var(--space-4) var(--space-5); background: none; border: none; cursor: pointer; text-align: left; - color: var(--color-text-heading); - transition: background-color 0.15s; + color: var(--color-text); + transition: background-color var(--duration-fast); } .sectionHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .sectionTitle { - font-size: 1rem; - font-weight: 600; + font-size: var(--font-lg); + font-weight: var(--font-semibold); margin: 0; } @@ -113,7 +107,7 @@ height: 20px; flex-shrink: 0; color: var(--color-text-muted); - transition: transform 0.2s; + transition: transform var(--duration-normal); transform: rotate(-90deg); } @@ -122,23 +116,23 @@ } .sectionBody { - padding: 0 1.25rem 1.25rem; + padding: 0 var(--space-5) var(--space-5); border-top: 1px solid var(--color-border); - padding-top: 1.25rem; + padding-top: var(--space-5); } .sectionDescription { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); - margin: 0 0 1.25rem 0; + margin: 0 0 var(--space-5) 0; } .subsectionTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-primary); - margin: 1.25rem 0 0.75rem 0; - padding-top: 1rem; + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: var(--space-5) 0 var(--space-3) 0; + padding-top: var(--space-4); border-top: 1px solid var(--color-border); } @@ -146,7 +140,7 @@ .fieldGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 1.25rem; + gap: var(--space-5); } .field { @@ -156,25 +150,25 @@ } .label { - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); } .input { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .input:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .inputError { @@ -182,35 +176,35 @@ } .inputError:focus { - border-color: var(--color-error); - box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); + border-color: var(--color-critical); + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15); } .textarea { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); resize: vertical; - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .textarea:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .fieldError { - font-size: 0.75rem; - color: var(--color-error); + font-size: var(--font-xs); + color: var(--color-critical); } .hint { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } @@ -218,21 +212,21 @@ .actions { display: flex; justify-content: flex-end; - margin-top: 1.5rem; - padding-top: 1rem; + margin-top: var(--space-5); + padding-top: var(--space-4); border-top: 1px solid var(--color-border); } .saveButton { - padding: 0.5rem 1.25rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-5); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: 1px solid transparent; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .saveButton:hover:not(:disabled) { @@ -250,17 +244,13 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-6); color: var(--color-text-muted); } .spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -274,32 +264,32 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-6); + color: var(--color-critical); text-align: center; } .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .fieldGrid { diff --git a/client/src/components/pages/Admin/AdminSettings.tsx b/client/src/components/pages/Admin/AdminSettings.tsx index 76eeffd..e2759ea 100644 --- a/client/src/components/pages/Admin/AdminSettings.tsx +++ b/client/src/components/pages/Admin/AdminSettings.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { ChevronDown, Loader2 } from 'lucide-react'; import { fetchSettings, updateSettings } from '../../../api/settings'; import type { SettingValue } from '../../../api/settings'; import styles from './AdminSettings.module.css'; @@ -212,7 +213,7 @@ function AdminSettings() { return (
-
+ Loading settings...
@@ -265,15 +266,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('retention')} >

Data Retention

- - - + /> {expandedSections.has('retention') && (
@@ -328,15 +324,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('polling')} >

Polling Defaults

- - - + /> {expandedSections.has('polling') && (
@@ -376,15 +367,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('security')} >

Security

- - - + /> {expandedSections.has('security') && (
@@ -500,15 +486,10 @@ function AdminSettings() { aria-expanded={expandedSections.has('alerts')} >

Alerts

- - - + /> {expandedSections.has('alerts') && (
diff --git a/client/src/components/pages/Admin/AlertMutesAdmin.module.css b/client/src/components/pages/Admin/AlertMutesAdmin.module.css index 773f444..4cefb78 100644 --- a/client/src/components/pages/Admin/AlertMutesAdmin.module.css +++ b/client/src/components/pages/Admin/AlertMutesAdmin.module.css @@ -2,63 +2,63 @@ .container { max-width: 1200px; margin: 0 auto; - padding: 1.5rem; + padding: var(--space-5); } .header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-primary); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); } .filterRow { display: flex; - gap: 0.75rem; - margin-bottom: 1rem; + gap: var(--space-3); + margin-bottom: var(--space-4); align-items: center; } .filterSelect { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); } .muteTable { width: 100%; border-collapse: collapse; - font-size: 0.875rem; - background-color: var(--color-bg-card); + font-size: var(--font-base); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-lg); overflow: hidden; } .muteTable th { text-align: left; - padding: 0.75rem; - font-weight: 500; + padding: var(--space-3); + font-weight: var(--font-medium); color: var(--color-text-muted); - font-size: 0.75rem; + font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.025em; border-bottom: 1px solid var(--color-border); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .muteTable td { - padding: 0.75rem; - color: var(--color-text-primary); + padding: var(--space-3); + color: var(--color-text); border-bottom: 1px solid var(--color-border); } @@ -68,32 +68,27 @@ .muteType { display: inline-flex; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); color: var(--color-text-muted); } .noMutes { text-align: center; - padding: 2rem; + padding: var(--space-6); color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); } .error { - color: var(--color-error, #dc3545); - font-size: 0.875rem; - margin-bottom: 0.75rem; - padding: 0.5rem 0.75rem; - background-color: #fef2f2; - border: 1px solid #fecaca; - border-radius: 0.375rem; -} - -[data-theme="dark"] .error { - background-color: #450a0a; - border-color: #7f1d1d; + color: var(--color-critical); + font-size: var(--font-base); + margin-bottom: var(--space-3); + padding: var(--space-2) var(--space-3); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-critical) 30%, transparent); + border-radius: var(--radius-md); } diff --git a/client/src/components/pages/Admin/ManifestAdmin.module.css b/client/src/components/pages/Admin/ManifestAdmin.module.css index bc45d92..ed2235a 100644 --- a/client/src/components/pages/Admin/ManifestAdmin.module.css +++ b/client/src/components/pages/Admin/ManifestAdmin.module.css @@ -1,7 +1,7 @@ /* Container & Layout */ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; @@ -11,21 +11,21 @@ display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } /* Filters & Actions Bar */ .toolbar { display: flex; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--space-4); + margin-bottom: var(--space-5); align-items: center; } @@ -37,7 +37,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -46,35 +46,35 @@ .searchInput { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.5rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3) var(--space-2) 2.5rem; + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .searchInput:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .syncAllButton { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .syncAllButton:hover:not(:disabled) { @@ -88,9 +88,9 @@ /* Table */ .tableWrapper { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-lg); overflow: auto; } @@ -102,21 +102,21 @@ .table th { text-align: left; - padding: 0.75rem 1rem; - font-size: 0.75rem; - font-weight: 600; + padding: var(--space-3) var(--space-4); + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); white-space: nowrap; } .table td { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-text-primary); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-text); border-bottom: 1px solid var(--color-border); vertical-align: middle; } @@ -126,13 +126,13 @@ } .table tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .teamLink { color: var(--color-accent); text-decoration: none; - font-weight: 500; + font-weight: var(--font-medium); } .teamLink:hover { @@ -145,32 +145,27 @@ text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } /* Badges */ .badge { display: inline-flex; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; white-space: nowrap; } .badgeEnabled { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .badgeEnabled { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .badgeDisabled { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .badgeNone { @@ -179,51 +174,36 @@ } .badgeSuccess { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .badgeSuccess { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 10%, transparent); } .badgeFailed { - color: #991b1b; - background-color: #fee2e2; -} - -[data-theme="dark"] .badgeFailed { - color: #fca5a5; - background-color: #450a0a; + color: var(--color-critical); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); } .badgePartial { - color: #92400e; - background-color: #fef3c7; -} - -[data-theme="dark"] .badgePartial { - color: #fcd34d; - background-color: #451a03; + color: var(--color-warning); + background-color: color-mix(in srgb, var(--color-warning) 10%, transparent); } .badgeNever { color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .driftCount { - font-weight: 600; + font-weight: var(--font-semibold); font-variant-numeric: tabular-nums; } .driftCountPositive { - color: #dc2626; + color: var(--color-critical); } .contactCell { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); max-width: 150px; overflow: hidden; @@ -233,47 +213,47 @@ /* Sync Results */ .syncResults { - margin-bottom: 1.5rem; - padding: 1rem; - background-color: var(--color-bg-card); + margin-bottom: var(--space-5); + padding: var(--space-4); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; + border-radius: var(--radius-lg); } .syncResultsHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .syncResultsTitle { - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); } .dismissButton { padding: 0.125rem 0.375rem; - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); background: none; - border: 1px solid var(--color-border-input); - border-radius: 0.25rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; } .dismissButton:hover { - color: var(--color-text-primary); + color: var(--color-text); border-color: var(--color-text-muted); } .syncResultItem { display: flex; align-items: center; - gap: 0.5rem; - font-size: 0.8125rem; - padding: 0.25rem 0; + gap: var(--space-2); + font-size: var(--font-sm); + padding: var(--space-1) 0; } /* States */ @@ -282,17 +262,13 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-6); color: var(--color-text-muted); } .spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -306,34 +282,34 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-6); + color: var(--color-critical); text-align: center; } .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .emptyState { - padding: 4rem 2rem; + padding: var(--space-8) var(--space-6); text-align: center; color: var(--color-text-muted); } .relativeTime { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); } diff --git a/client/src/components/pages/Admin/ManifestAdmin.tsx b/client/src/components/pages/Admin/ManifestAdmin.tsx index 5ff8741..9a61de9 100644 --- a/client/src/components/pages/Admin/ManifestAdmin.tsx +++ b/client/src/components/pages/Admin/ManifestAdmin.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Search, Loader2 } from 'lucide-react'; import { Link } from 'react-router-dom'; import { fetchAdminManifests, @@ -71,7 +72,7 @@ function ManifestAdmin() { return (
-
+ Loading manifest configurations...
@@ -99,18 +100,7 @@ function ManifestAdmin() {
- - - - +
-
+ Loading users...
@@ -353,18 +354,7 @@ function UserManagement() {
- - - - + removeAlias(a.id)} title="Delete alias" > - - - +
)} diff --git a/client/src/components/pages/Associations/AssociationForm.module.css b/client/src/components/pages/Associations/AssociationForm.module.css index ddbefaf..4cd528c 100644 --- a/client/src/components/pages/Associations/AssociationForm.module.css +++ b/client/src/components/pages/Associations/AssociationForm.module.css @@ -1,7 +1,7 @@ .form { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-4); } .field { @@ -11,22 +11,22 @@ } .fieldLabel { - font-size: 0.8125rem; - font-weight: 600; + font-size: var(--font-sm); + font-weight: var(--font-semibold); color: var(--color-text-muted); } .select { - padding: 0.5rem 2rem 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-6) var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; + background-position: right var(--space-2) center; background-repeat: no-repeat; background-size: 1.5rem 1.5rem; } @@ -34,26 +34,26 @@ .select:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .actions { display: flex; justify-content: flex-end; - gap: 0.5rem; - margin-top: 0.5rem; + gap: var(--space-2); + margin-top: var(--space-2); } .submitButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .submitButton:hover:not(:disabled) { @@ -66,32 +66,32 @@ } .cancelButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .cancelButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .error { - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; - color: var(--color-error); + padding: var(--space-2) var(--space-3); + font-size: var(--font-sm); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .loading { - padding: 2rem; + padding: var(--space-6); text-align: center; color: var(--color-text-muted); } diff --git a/client/src/components/pages/Associations/AssociationsPage.module.css b/client/src/components/pages/Associations/AssociationsPage.module.css index 090a006..88b0017 100644 --- a/client/src/components/pages/Associations/AssociationsPage.module.css +++ b/client/src/components/pages/Associations/AssociationsPage.module.css @@ -1,19 +1,19 @@ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; } .header { - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } @@ -21,27 +21,27 @@ display: flex; gap: 0; border-bottom: 2px solid var(--color-border); - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); } .tab { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.25rem; - font-size: 0.875rem; - font-weight: 500; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + font-size: var(--font-base); + font-weight: var(--font-medium); color: var(--color-text-muted); background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -2px; cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast), border-color var(--duration-fast); } .tab:hover { - color: var(--color-text-primary); + color: var(--color-text); } .tabActive { @@ -57,8 +57,8 @@ height: 1.25rem; padding: 0 0.375rem; font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-inverse); + font-weight: var(--font-semibold); + color: #fff; background-color: var(--color-accent); border-radius: 9999px; } @@ -69,7 +69,7 @@ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .tabs { diff --git a/client/src/components/pages/Associations/ExternalServicesManager.module.css b/client/src/components/pages/Associations/ExternalServicesManager.module.css index b5df13f..9930a91 100644 --- a/client/src/components/pages/Associations/ExternalServicesManager.module.css +++ b/client/src/components/pages/Associations/ExternalServicesManager.module.css @@ -1,43 +1,43 @@ .container { display: flex; flex-direction: column; - gap: 1.5rem; + gap: var(--space-5); } .description { - font-size: 0.875rem; + font-size: var(--font-base); color: var(--color-text-muted); margin: 0; } .form { display: flex; - gap: 0.75rem; + gap: var(--space-3); align-items: flex-end; } .field { display: flex; flex-direction: column; - gap: 0.25rem; + gap: var(--space-1); flex: 1; } .fieldLabel { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.025em; } .input { - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); } .input::placeholder { @@ -45,31 +45,31 @@ } .select { - padding: 0.5rem 2rem 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-6) var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; + background-position: right var(--space-2) center; background-repeat: no-repeat; background-size: 1.5rem 1.5rem; } .addButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; - transition: opacity 0.15s; + transition: opacity var(--duration-fast); } .addButton:hover { @@ -82,23 +82,23 @@ } .error { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-error, #dc3545); - background-color: var(--color-bg-error, rgba(220, 53, 69, 0.1)); - border-radius: 0.375rem; + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-critical); + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); + border-radius: var(--radius-md); } .empty { text-align: center; - padding: 2rem; + padding: var(--space-6); color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); } .loading { text-align: center; - padding: 2rem; + padding: var(--space-6); color: var(--color-text-muted); } @@ -109,28 +109,28 @@ .table { width: 100%; border-collapse: collapse; - font-size: 0.875rem; + font-size: var(--font-base); } .table th { text-align: left; - padding: 0.75rem; - font-weight: 600; + padding: var(--space-3); + font-weight: var(--font-semibold); color: var(--color-text-muted); border-bottom: 2px solid var(--color-border); - font-size: 0.75rem; + font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.025em; } .table td { - padding: 0.75rem; + padding: var(--space-3); border-bottom: 1px solid var(--color-border); - color: var(--color-text-primary); + color: var(--color-text); } .nameCell { - font-weight: 500; + font-weight: var(--font-medium); } .descCell { @@ -146,7 +146,7 @@ .actionsCell { display: flex; - gap: 0.5rem; + gap: var(--space-2); white-space: nowrap; } @@ -158,42 +158,42 @@ height: 1.75rem; background: none; border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); color: var(--color-text-muted); cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast), border-color var(--duration-fast); } .iconButton:hover { - color: var(--color-text-primary); + color: var(--color-text); border-color: var(--color-text-muted); } .deleteButton:hover { - color: var(--color-error, #dc3545); - border-color: var(--color-error, #dc3545); + color: var(--color-critical); + border-color: var(--color-critical); } .editInput { - padding: 0.375rem 0.5rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.25rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: 0.375rem var(--space-2); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); width: 100%; } .saveButton { - padding: 0.25rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-1) var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; - transition: opacity 0.15s; + transition: opacity var(--duration-fast); } .saveButton:hover { @@ -206,19 +206,19 @@ } .cancelButton { - padding: 0.25rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; + padding: var(--space-1) var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-text-muted); background: none; border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast), border-color var(--duration-fast); } .cancelButton:hover { - color: var(--color-text-primary); + color: var(--color-text); border-color: var(--color-text-muted); } diff --git a/client/src/components/pages/Associations/ExternalServicesManager.tsx b/client/src/components/pages/Associations/ExternalServicesManager.tsx index 71b3899..3087dc1 100644 --- a/client/src/components/pages/Associations/ExternalServicesManager.tsx +++ b/client/src/components/pages/Associations/ExternalServicesManager.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, FormEvent } from 'react'; +import { Pencil, Trash2 } from 'lucide-react'; import { useAuth } from '../../../contexts/AuthContext'; import { fetchTeams } from '../../../api/teams'; import { @@ -257,18 +258,14 @@ function ExternalServicesManager() { onClick={() => startEdit(svc)} title="Edit" > - - - + )} diff --git a/client/src/components/pages/Associations/ManageAssociations.module.css b/client/src/components/pages/Associations/ManageAssociations.module.css index 1b60ddf..2433fc6 100644 --- a/client/src/components/pages/Associations/ManageAssociations.module.css +++ b/client/src/components/pages/Associations/ManageAssociations.module.css @@ -1,7 +1,7 @@ .searchBar { display: flex; - gap: 0.75rem; - margin-bottom: 1.5rem; + gap: var(--space-3); + margin-bottom: var(--space-5); } .searchWrapper { @@ -12,7 +12,7 @@ .searchIcon { position: absolute; - left: 0.75rem; + left: var(--space-3); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -21,12 +21,12 @@ .searchInput { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.25rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-3) var(--space-2) 2.25rem; + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); } .searchInput::placeholder { @@ -36,20 +36,20 @@ .searchInput:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .filterSelect { - padding: 0.5rem 2rem 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-6) var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); - background-position: right 0.5rem center; + background-position: right var(--space-2) center; background-repeat: no-repeat; background-size: 1.5rem 1.5rem; } @@ -57,41 +57,41 @@ .filterSelect:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .serviceGroup { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - margin-bottom: 0.75rem; + border-radius: var(--radius-lg); + margin-bottom: var(--space-3); overflow: hidden; } .serviceHeader { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 100%; - padding: 0.75rem 1rem; - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-heading); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); background: none; border: none; cursor: pointer; text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .serviceHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .chevron { display: inline-flex; color: var(--color-text-muted); - transition: transform 0.15s; + transition: transform var(--duration-fast); flex-shrink: 0; } @@ -108,8 +108,8 @@ } .depCount { - font-size: 0.75rem; - font-weight: 500; + font-size: var(--font-xs); + font-weight: var(--font-medium); color: var(--color-text-muted); white-space: nowrap; } @@ -129,25 +129,25 @@ .depHeader { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 100%; - padding: 0.625rem 1rem 0.625rem 2rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + padding: 0.625rem var(--space-4) 0.625rem var(--space-6); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); background: none; border: none; cursor: pointer; text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .depHeader:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .depHeaderExpanded { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .depName { @@ -166,8 +166,8 @@ height: 1.25rem; padding: 0 0.375rem; font-size: 0.6875rem; - font-weight: 600; - color: var(--color-text-inverse); + font-weight: var(--font-semibold); + color: #fff; background-color: var(--color-accent); border-radius: 9999px; } @@ -177,8 +177,8 @@ } .depPanel { - padding: 0.75rem 1rem 0.75rem 2rem; - background-color: var(--color-bg-hover); + padding: var(--space-3) var(--space-4) var(--space-3) var(--space-6); + background-color: var(--color-surface-hover); border-top: 1px solid var(--color-border); } @@ -186,24 +186,24 @@ display: flex; flex-direction: column; gap: 0.375rem; - margin-bottom: 0.75rem; + margin-bottom: var(--space-3); } .assocItem { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: var(--color-bg-card); + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; - font-size: 0.875rem; + border-radius: var(--radius-md); + font-size: var(--font-base); } .assocServiceName { flex: 1; - font-weight: 500; - color: var(--color-text-primary); + font-weight: var(--font-medium); + color: var(--color-text); min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -212,12 +212,12 @@ .typeBadge { display: inline-block; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background-color: var(--color-surface-hover); + color: var(--color-text); white-space: nowrap; } @@ -229,17 +229,17 @@ height: 1.75rem; padding: 0; border: 1px solid var(--color-border); - background: var(--color-bg-card); + background: var(--color-surface); color: var(--color-text-muted); cursor: pointer; - border-radius: 0.25rem; + border-radius: var(--radius-sm); flex-shrink: 0; - transition: all 0.15s; + transition: all var(--duration-fast); } .deleteButton:hover { background: var(--color-error-bg); - color: var(--color-error); + color: var(--color-critical); border-color: var(--color-error-border); } @@ -247,72 +247,72 @@ display: inline-flex; align-items: center; gap: 0.375rem; - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; + padding: 0.375rem var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-accent); background: none; border: 1px dashed var(--color-accent); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .addButton:hover { - background-color: rgba(59, 130, 246, 0.05); + background-color: color-mix(in srgb, var(--color-accent) 5%, transparent); } .formWrapper { - margin-top: 0.75rem; - padding: 0.75rem; - background-color: var(--color-bg-card); + margin-top: var(--space-3); + padding: var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .noAssociations { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); - padding: 0.25rem 0; - margin-bottom: 0.75rem; + padding: var(--space-1) 0; + margin-bottom: var(--space-3); } .loading { - padding: 3rem; + padding: var(--space-7); text-align: center; color: var(--color-text-muted); - font-size: 0.875rem; + font-size: var(--font-base); } .error { - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + font-size: var(--font-base); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; - margin-bottom: 1rem; + border-radius: var(--radius-md); + margin-bottom: var(--space-4); } .empty { - padding: 3rem; + padding: var(--space-7); text-align: center; color: var(--color-text-muted); - font-size: 0.875rem; - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + font-size: var(--font-base); + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); } .aliasSection { - margin-top: 0.75rem; - padding-top: 0.75rem; + margin-top: var(--space-3); + padding-top: var(--space-3); border-top: 1px solid var(--color-border); } .aliasSectionHeader { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; @@ -323,24 +323,24 @@ display: flex; flex-direction: column; gap: 0.375rem; - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .aliasItem { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: var(--color-bg-card); + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; - font-size: 0.875rem; + border-radius: var(--radius-md); + font-size: var(--font-base); } .aliasCanonical { flex: 1; - font-weight: 500; - color: var(--color-text-primary); + font-weight: var(--font-medium); + color: var(--color-text); min-width: 0; overflow: hidden; text-overflow: ellipsis; @@ -351,35 +351,35 @@ display: inline-flex; align-items: center; gap: 0.375rem; - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; + padding: 0.375rem var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-accent); background: none; border: 1px dashed var(--color-accent); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .addAliasButton:hover { - background-color: rgba(59, 130, 246, 0.05); + background-color: color-mix(in srgb, var(--color-accent) 5%, transparent); } .aliasForm { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .aliasInput { flex: 1; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); } .aliasInput::placeholder { @@ -389,61 +389,61 @@ .aliasInput:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .aliasError { - font-size: 0.75rem; - color: var(--color-error); - margin-top: 0.25rem; + font-size: var(--font-xs); + color: var(--color-critical); + margin-top: var(--space-1); } /* Canonical Override Section */ .canonicalOverrideSection { - margin-top: 0.75rem; - padding-top: 0.75rem; + margin-top: var(--space-3); + padding-top: var(--space-3); border-top: 1px solid var(--color-border); } .canonicalOverrideNote { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); font-style: italic; - padding: 0.25rem 0; + padding: var(--space-1) 0; } .canonicalOverrideDisplay { - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .overrideIndicator { display: inline-block; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; - background-color: rgba(59, 130, 246, 0.1); + background-color: color-mix(in srgb, var(--color-accent) 10%, transparent); color: var(--color-accent); - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .overrideFieldGroup { display: flex; align-items: baseline; gap: 0.375rem; - font-size: 0.8125rem; - margin-bottom: 0.25rem; + font-size: var(--font-sm); + margin-bottom: var(--space-1); } .overrideFieldLabel { - font-weight: 600; + font-weight: var(--font-semibold); color: var(--color-text-muted); flex-shrink: 0; } .overrideFieldValue { - color: var(--color-text-primary); + color: var(--color-text); } .overrideContactList { @@ -454,30 +454,30 @@ .overrideContactItem { display: inline-block; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - background-color: var(--color-bg-card); + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.25rem; - color: var(--color-text-primary); + border-radius: var(--radius-sm); + color: var(--color-text); } .overrideForm { - margin-top: 0.5rem; - padding: 0.75rem; - background-color: var(--color-bg-card); + margin-top: var(--space-2); + padding: var(--space-3); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .overrideFormGroup { - margin-bottom: 0.75rem; + margin-bottom: var(--space-3); } .overrideFormLabel { display: block; - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; @@ -487,29 +487,29 @@ .overrideContactEntryRow { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); margin-bottom: 0.375rem; } .overrideFormActions { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .overrideClearButton { display: inline-flex; align-items: center; gap: 0.375rem; - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-error); + padding: 0.375rem var(--space-3); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-critical); background: none; border: 1px dashed var(--color-error-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .overrideClearButton:hover { @@ -531,10 +531,10 @@ } .depHeader { - padding-left: 1.25rem; + padding-left: var(--space-5); } .depPanel { - padding-left: 1.25rem; + padding-left: var(--space-5); } } diff --git a/client/src/components/pages/Associations/ManageAssociations.tsx b/client/src/components/pages/Associations/ManageAssociations.tsx index ff5a943..ec203f2 100644 --- a/client/src/components/pages/Associations/ManageAssociations.tsx +++ b/client/src/components/pages/Associations/ManageAssociations.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; +import { Search, ChevronRight, X } from 'lucide-react'; import { useManageAssociations } from '../../../hooks/useManageAssociations'; import { useAliases } from '../../../hooks/useAliases'; import { useCanonicalOverrides } from '../../../hooks/useCanonicalOverrides'; @@ -286,9 +287,7 @@ function ManageAssociations() { onClick={() => removeContactEntry(i)} title="Remove entry" > - - - +
))} @@ -364,9 +363,7 @@ function ManageAssociations() { onClick={() => handleAliasDelete(a.id)} title="Delete alias" > - - - + )}
@@ -442,18 +439,7 @@ function ManageAssociations() {
- - - - + - - - + {service.name} @@ -519,9 +503,7 @@ function ManageAssociations() { aria-expanded={isDepExpanded} > - - - + {dep.name} {assocs !== undefined && ( @@ -554,9 +536,7 @@ function ManageAssociations() { onClick={() => setDeleteTarget({ depId: dep.id, assoc })} title="Delete association" > - - - +
))} diff --git a/client/src/components/pages/Manifest/DriftReview.module.css b/client/src/components/pages/Manifest/DriftReview.module.css index 92b4620..c670be8 100644 --- a/client/src/components/pages/Manifest/DriftReview.module.css +++ b/client/src/components/pages/Manifest/DriftReview.module.css @@ -1,40 +1,40 @@ /* Sub-navigation toggle */ .viewToggle { display: inline-flex; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); overflow: hidden; - margin-bottom: 1rem; + margin-bottom: var(--space-4); } .viewButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); border: none; - background-color: var(--color-bg-card); - color: var(--color-text-primary); + background-color: var(--color-surface); + color: var(--color-text); cursor: pointer; - transition: background-color 0.15s, color 0.15s; + transition: background-color var(--duration-fast), color var(--duration-fast); } .viewButton:first-child { - border-right: 1px solid var(--color-border-input); + border-right: 1px solid var(--color-border); } .viewButtonActive { background-color: var(--color-accent); - color: var(--color-text-inverse); + color: #fff; } .viewButton:hover:not(.viewButtonActive) { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .viewCount { - font-size: 0.75rem; - font-weight: 600; - margin-left: 0.25rem; + font-size: var(--font-xs); + font-weight: var(--font-semibold); + margin-left: var(--space-1); opacity: 0.8; } @@ -42,24 +42,24 @@ .toolbar { display: flex; align-items: center; - gap: 0.75rem; + gap: var(--space-3); flex-wrap: wrap; - margin-bottom: 1rem; + margin-bottom: var(--space-4); } .filters { display: flex; - gap: 0.5rem; + gap: var(--space-2); flex: 1; } .filterSelect { - padding: 0.375rem 0.625rem; - font-size: 0.8125rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) 0.625rem; + font-size: var(--font-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); } .filterSelect:focus { @@ -70,26 +70,26 @@ .bulkActions { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .selectedCount { - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); white-space: nowrap; } .bulkButton { - padding: 0.375rem 0.625rem; - font-size: 0.8125rem; - font-weight: 500; - border-radius: 0.375rem; + padding: var(--space-2) 0.625rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .bulkAccept { - color: var(--color-text-inverse); + color: #fff; background-color: var(--color-accent); border: none; } @@ -99,13 +99,13 @@ } .bulkDismiss { - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); } .bulkDismiss:hover:not(:disabled) { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .bulkButton:disabled { @@ -117,10 +117,10 @@ .selectAllRow { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 0; - margin-bottom: 0.5rem; - font-size: 0.8125rem; + gap: var(--space-2); + padding: var(--space-2) 0; + margin-bottom: var(--space-2); + font-size: var(--font-sm); color: var(--color-text-muted); } @@ -133,30 +133,30 @@ .flagsList { display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--space-2); } /* Empty state */ .emptyState { - padding: 2rem; + padding: var(--space-6); text-align: center; color: var(--color-text-muted); - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); } .error { - padding: 0.75rem 1rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; - font-size: 0.875rem; + border-radius: var(--radius-md); + font-size: var(--font-base); } .loading { - padding: 2rem; + padding: var(--space-6); text-align: center; color: var(--color-text-muted); } @@ -164,16 +164,16 @@ /* Drift flag card */ .flagCard { display: flex; - gap: 0.75rem; - padding: 1rem; - background-color: var(--color-bg-card); + gap: var(--space-3); + padding: var(--space-4); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - transition: border-color 0.15s; + border-radius: var(--radius-lg); + transition: border-color var(--duration-fast); } .flagCard:hover { - border-color: var(--color-border-input); + border-color: var(--color-border); } .flagCardDismissed { @@ -194,7 +194,7 @@ display: flex; justify-content: space-between; align-items: flex-start; - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } .flagServiceInfo { @@ -204,22 +204,22 @@ } .flagServiceName { - font-weight: 600; - font-size: 0.875rem; - color: var(--color-text-heading); + font-weight: var(--font-semibold); + font-size: var(--font-base); + color: var(--color-text); } .flagManifestKey { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); - font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; } .driftTypeBadge { display: inline-block; - padding: 0.125rem 0.5rem; + padding: 0.125rem var(--space-2); font-size: 0.6875rem; - font-weight: 600; + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.04em; border-radius: 3px; @@ -233,24 +233,24 @@ } .badgeServiceRemoval { - color: var(--color-error); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); } /* Field diff */ .fieldName { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-primary); - margin-bottom: 0.375rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); + margin-bottom: var(--space-2); } .fieldDiff { display: grid; grid-template-columns: 1fr 1fr; - gap: 0.5rem; - margin-bottom: 0.5rem; + gap: var(--space-2); + margin-bottom: var(--space-2); } .diffColumn { @@ -261,72 +261,72 @@ .diffLabel { font-size: 0.6875rem; - font-weight: 600; + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); } .diffValue { - font-size: 0.8125rem; - color: var(--color-text-primary); + font-size: var(--font-sm); + color: var(--color-text); word-break: break-all; - padding: 0.375rem 0.5rem; - background-color: var(--color-bg-hover); - border-radius: 0.25rem; - font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace); - font-size: 0.75rem; + padding: var(--space-2) var(--space-2); + background-color: var(--color-surface-hover); + border-radius: var(--radius-sm); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); } .diffValueCurrent { - border-left: 3px solid var(--color-error); + border-left: 3px solid var(--color-critical); } .diffValueManifest { - border-left: 3px solid var(--color-success, #16a34a); + border-left: 3px solid var(--color-healthy); } /* Service removal message */ .removalMessage { - font-size: 0.875rem; + font-size: var(--font-base); color: var(--color-text-muted); - padding: 0.75rem; - background-color: var(--color-bg-hover); - border-radius: 0.375rem; - margin-bottom: 0.5rem; + padding: var(--space-3); + background-color: var(--color-surface-hover); + border-radius: var(--radius-md); + margin-bottom: var(--space-2); } /* Timestamps */ .flagTimestamps { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } /* Dismissed info */ .dismissedInfo { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); font-style: italic; - margin-bottom: 0.5rem; + margin-bottom: var(--space-2); } /* Actions */ .flagActions { display: flex; - gap: 0.5rem; + gap: var(--space-2); } .acceptButton { - padding: 0.375rem 0.625rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-2) 0.625rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .acceptButton:hover:not(:disabled) { @@ -334,31 +334,31 @@ } .dismissButton { - padding: 0.375rem 0.625rem; - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) 0.625rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .dismissButton:hover:not(:disabled) { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .reopenButton { - padding: 0.375rem 0.625rem; - font-size: 0.8125rem; - font-weight: 500; + padding: var(--space-2) 0.625rem; + font-size: var(--font-sm); + font-weight: var(--font-medium); color: var(--color-accent); - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .reopenButton:hover:not(:disabled) { @@ -374,33 +374,33 @@ .inlineConfirm { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.375rem 0.625rem; - font-size: 0.8125rem; - color: var(--color-error); + gap: var(--space-2); + padding: var(--space-2) 0.625rem; + font-size: var(--font-sm); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .inlineConfirm button { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 600; - border-radius: 0.25rem; + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-semibold); + border-radius: var(--radius-sm); cursor: pointer; } .confirmYes { - color: var(--color-text-inverse); - background-color: var(--color-error); + color: #fff; + background-color: var(--color-critical); border: none; } .confirmNo { - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); } /* Responsive */ diff --git a/client/src/components/pages/Manifest/ManifestPage.module.css b/client/src/components/pages/Manifest/ManifestPage.module.css index e437ba1..a2b5bae 100644 --- a/client/src/components/pages/Manifest/ManifestPage.module.css +++ b/client/src/components/pages/Manifest/ManifestPage.module.css @@ -1,7 +1,7 @@ /* Container & Layout — mirrors Teams.module.css patterns */ .container { height: 100%; - padding: 2rem; + padding: var(--space-6); display: flex; flex-direction: column; overflow: auto; @@ -10,41 +10,41 @@ .backLink { display: inline-flex; align-items: center; - gap: 0.375rem; + gap: var(--space-2); color: var(--color-text-muted); text-decoration: none; - font-size: 0.875rem; - margin-bottom: 1rem; - transition: color 0.15s; + font-size: var(--font-base); + margin-bottom: var(--space-4); + transition: color var(--duration-fast); } .backLink:hover { - color: var(--color-text-primary); + color: var(--color-text); } .pageTitle { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); - margin: 0 0 1.5rem 0; + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0 0 var(--space-5) 0; } /* Section */ .section { - margin-bottom: 2rem; + margin-bottom: var(--space-6); } .sectionHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; + margin-bottom: var(--space-4); } .sectionTitle { - font-size: 1.125rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-xl); + font-weight: var(--font-semibold); + color: var(--color-text); margin: 0; } @@ -54,17 +54,13 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-6); color: var(--color-text-muted); } .spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } @@ -78,27 +74,27 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; - color: var(--color-error); + gap: var(--space-4); + padding: var(--space-8) var(--space-6); + color: var(--color-critical); text-align: center; } .retryButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); text-decoration: none; } .retryButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } /* Empty state (no manifest configured) */ @@ -106,34 +102,34 @@ display: flex; flex-direction: column; align-items: center; - gap: 1rem; - padding: 4rem 2rem; + gap: var(--space-4); + padding: var(--space-8) var(--space-6); color: var(--color-text-muted); text-align: center; - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); } .emptyState p { margin: 0; max-width: 28rem; - line-height: 1.5; + line-height: var(--line-height-normal); } .configureButton { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .configureButton:hover { @@ -142,28 +138,28 @@ /* Inline error banner */ .errorBanner { - padding: 0.75rem 1rem; - margin-bottom: 1rem; - color: var(--color-error); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-4); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); - border-radius: 0.375rem; - font-size: 0.875rem; + border-radius: var(--radius-md); + font-size: var(--font-base); } /* Config display */ .configCard { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.25rem; + border-radius: var(--radius-lg); + padding: var(--space-5); } .configRow { display: flex; align-items: flex-start; - gap: 0.5rem; - margin-bottom: 0.75rem; + gap: var(--space-2); + margin-bottom: var(--space-3); } .configRow:last-child { @@ -171,8 +167,8 @@ } .configLabel { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); @@ -181,31 +177,31 @@ } .configValue { - font-size: 0.875rem; - color: var(--color-text-primary); + font-size: var(--font-base); + color: var(--color-text); word-break: break-all; } .configValue code { - font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace); - font-size: 0.8125rem; - padding: 0.125rem 0.375rem; - background-color: var(--color-bg-hover); - border-radius: 0.25rem; + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-sm); + padding: 0.125rem var(--space-2); + background-color: var(--color-surface-hover); + border-radius: var(--radius-sm); } .policyLabel { display: inline-block; - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; + padding: 0.125rem var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); border-radius: 9999px; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .statusEnabled { - color: var(--color-success, #16a34a); + color: var(--color-healthy); } .statusDisabled { @@ -214,54 +210,54 @@ .configActions { display: flex; - gap: 0.5rem; - margin-top: 1rem; - padding-top: 1rem; + gap: var(--space-2); + margin-top: var(--space-4); + padding-top: var(--space-4); border-top: 1px solid var(--color-border); } .actionButton { display: inline-flex; align-items: center; - gap: 0.375rem; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - font-weight: 500; - border-radius: 0.375rem; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + font-weight: var(--font-medium); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); } .editButton { - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); } .editButton:hover:not(:disabled) { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-color: var(--color-text-muted); } .disableButton { - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); } .disableButton:hover:not(:disabled) { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .removeButton { - color: var(--color-error); - background-color: var(--color-bg-card); + color: var(--color-critical); + background-color: var(--color-surface); border: 1px solid var(--color-error-border); } .removeButton:hover:not(:disabled) { background-color: var(--color-error-bg); - border-color: var(--color-error); + border-color: var(--color-critical); } .actionButton:disabled { @@ -273,18 +269,18 @@ .configForm { display: flex; flex-direction: column; - gap: 1rem; + gap: var(--space-4); } .field { display: flex; flex-direction: column; - gap: 0.25rem; + gap: var(--space-1); } .label { - font-size: 0.75rem; - font-weight: 600; + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); @@ -292,50 +288,50 @@ .input { width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .input:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .inputError { - border-color: var(--color-error); + border-color: var(--color-critical); } .fieldError { - color: var(--color-error); - font-size: 0.75rem; + color: var(--color-critical); + font-size: var(--font-xs); } .select { width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); } .select:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } /* URL input with test button */ .urlInputRow { display: flex; - gap: 0.5rem; + gap: var(--space-2); align-items: flex-start; } @@ -345,20 +341,20 @@ .testButton { flex-shrink: 0; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); white-space: nowrap; } .testButton:hover:not(:disabled) { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-color: var(--color-text-muted); } @@ -370,29 +366,29 @@ /* Test result display */ .testResultBox { display: flex; - gap: 0.5rem; - padding: 0.625rem 0.75rem; - border-radius: 0.375rem; - font-size: 0.8125rem; + gap: var(--space-2); + padding: 0.625rem var(--space-3); + border-radius: var(--radius-md); + font-size: var(--font-sm); line-height: 1.4; - margin-top: 0.25rem; + margin-top: var(--space-1); } .testResultBox[data-status="success"] { - color: var(--color-success, #16a34a); - background-color: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--color-success, #16a34a) 20%, transparent); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 20%, transparent); } .testResultBox[data-status="error"] { - color: var(--color-error); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); } .testResultIcon { flex-shrink: 0; - font-size: 0.875rem; + font-size: var(--font-base); line-height: 1.4; } @@ -402,23 +398,23 @@ } .testResultDetail { - margin: 0.25rem 0 0; - font-size: 0.75rem; + margin: var(--space-1) 0 0; + font-size: var(--font-xs); opacity: 0.85; } .testIssueList { - margin: 0.375rem 0 0; - padding-left: 1.25rem; - font-size: 0.75rem; + margin: var(--space-2) 0 0; + padding-left: var(--space-5); + font-size: var(--font-xs); } .testIssueList[data-severity="error"] { - color: var(--color-error); + color: var(--color-critical); } .testIssueList[data-severity="warning"] { - color: var(--color-warning, #ca8a04); + color: var(--color-warning); } .testIssueList li { @@ -426,40 +422,40 @@ } .testIssueList code { - font-family: var(--font-mono, 'SFMono-Regular', 'Menlo', monospace); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; font-size: 0.6875rem; - padding: 0.0625rem 0.25rem; - background-color: rgba(0, 0, 0, 0.06); - border-radius: 0.1875rem; + padding: 0.0625rem var(--space-1); + background-color: var(--color-surface-hover); + border-radius: 3px; } .fieldHint { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } .deleteWarning { - color: var(--color-error); - font-size: 0.75rem; - font-weight: 500; + color: var(--color-critical); + font-size: var(--font-xs); + font-weight: var(--font-medium); } .formActions { display: flex; - gap: 0.5rem; - margin-top: 0.5rem; + gap: var(--space-2); + margin-top: var(--space-2); } .saveButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .saveButton:hover:not(:disabled) { @@ -472,49 +468,49 @@ } .cancelButton { - padding: 0.5rem 1rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); - background-color: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-4); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .cancelButton:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } /* Sync result */ .syncCard { - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - padding: 1.25rem; + border-radius: var(--radius-lg); + padding: var(--space-5); } .syncHeader { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 0.75rem; + margin-bottom: var(--space-3); } .syncButton { display: inline-flex; align-items: center; - gap: 0.375rem; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-inverse); + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: #fff; background-color: var(--color-accent); border: none; - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .syncButton:hover:not(:disabled) { @@ -529,11 +525,11 @@ .syncButtonGroup { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); } .cooldownText { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); white-space: nowrap; } @@ -541,9 +537,9 @@ .syncStatus { display: flex; align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - color: var(--color-text-primary); + gap: var(--space-2); + font-size: var(--font-base); + color: var(--color-text); } .statusDot { @@ -554,36 +550,36 @@ } .statusDotSuccess { - background-color: var(--color-success, #16a34a); + background-color: var(--color-healthy); } .statusDotPartial { - background-color: var(--color-warning, #ca8a04); + background-color: var(--color-warning); } .statusDotError { - background-color: var(--color-error); + background-color: var(--color-critical); } .syncMeta { color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } .syncError { - color: var(--color-error); - font-size: 0.875rem; - margin-top: 0.5rem; - padding: 0.5rem 0.75rem; + color: var(--color-critical); + font-size: var(--font-base); + margin-top: var(--space-2); + padding: var(--space-2) var(--space-3); background-color: var(--color-error-bg); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .syncSummaryGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); - gap: 0.75rem; - margin-top: 0.75rem; + gap: var(--space-3); + margin-top: var(--space-3); } .summaryItem { @@ -593,19 +589,19 @@ } .summaryValue { - font-size: 1.25rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-xl); + font-weight: var(--font-semibold); + color: var(--color-text); font-variant-numeric: tabular-nums; } .summaryLabel { - font-size: 0.75rem; + font-size: var(--font-xs); color: var(--color-text-muted); } .noSyncs { - padding: 2rem; + padding: var(--space-6); text-align: center; color: var(--color-text-muted); } @@ -614,15 +610,15 @@ .detailsToggle { display: inline-flex; align-items: center; - gap: 0.375rem; + gap: var(--space-2); padding: 0; - margin-top: 0.75rem; - font-size: 0.8125rem; + margin-top: var(--space-3); + font-size: var(--font-sm); color: var(--color-accent); background: none; border: none; cursor: pointer; - transition: color 0.15s; + transition: color var(--duration-fast); } .detailsToggle:hover { @@ -630,29 +626,29 @@ } .changeList { - margin-top: 0.75rem; + margin-top: var(--space-3); border-top: 1px solid var(--color-border); - padding-top: 0.75rem; + padding-top: var(--space-3); } .changeItem { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.375rem 0; - font-size: 0.8125rem; - color: var(--color-text-primary); + gap: var(--space-2); + padding: var(--space-2) 0; + font-size: var(--font-sm); + color: var(--color-text); } .changeIcon { - font-size: 0.875rem; + font-size: var(--font-base); flex-shrink: 0; - width: 1.25rem; + width: var(--space-5); text-align: center; } .changeCreated { - color: var(--color-success, #16a34a); + color: var(--color-healthy); } .changeUpdated { @@ -660,11 +656,11 @@ } .changeDrift { - color: var(--color-warning, #ca8a04); + color: var(--color-warning); } .changeRemoved { - color: var(--color-error); + color: var(--color-critical); } .changeUnchanged { @@ -673,22 +669,22 @@ .changeFields { color: var(--color-text-muted); - font-size: 0.75rem; + font-size: var(--font-xs); } .warningsList { - margin-top: 0.5rem; - padding: 0.5rem 0.75rem; - background-color: color-mix(in srgb, var(--color-warning, #ca8a04) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--color-warning, #ca8a04) 20%, transparent); - border-radius: 0.375rem; - font-size: 0.8125rem; - color: var(--color-warning, #ca8a04); + margin-top: var(--space-2); + padding: var(--space-2) var(--space-3); + background-color: color-mix(in srgb, var(--color-warning) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-warning) 20%, transparent); + border-radius: var(--radius-md); + font-size: var(--font-sm); + color: var(--color-warning); } .warningsList ul { margin: 0; - padding-left: 1.25rem; + padding-left: var(--space-5); } /* Sync result banner (inline after manual sync) */ @@ -696,20 +692,20 @@ display: flex; align-items: center; justify-content: space-between; - padding: 0.5rem 0.75rem; - margin-bottom: 0.75rem; - border-radius: 0.375rem; - font-size: 0.875rem; + padding: var(--space-2) var(--space-3); + margin-bottom: var(--space-3); + border-radius: var(--radius-md); + font-size: var(--font-base); } .syncResultSuccess { - color: var(--color-success, #16a34a); - background-color: color-mix(in srgb, var(--color-success, #16a34a) 8%, transparent); - border: 1px solid color-mix(in srgb, var(--color-success, #16a34a) 20%, transparent); + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 20%, transparent); } .syncResultError { - color: var(--color-error); + color: var(--color-critical); background-color: var(--color-error-bg); border: 1px solid var(--color-error-border); } @@ -717,10 +713,10 @@ .dismissBanner { background: none; border: none; - font-size: 1.125rem; + font-size: var(--font-xl); cursor: pointer; color: inherit; - padding: 0 0.25rem; + padding: 0 var(--space-1); line-height: 1; } @@ -734,30 +730,30 @@ .historyItem { display: flex; align-items: flex-start; - gap: 0.75rem; - padding: 0.75rem 1rem; - background-color: var(--color-bg-card); + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + background-color: var(--color-surface); border: 1px solid var(--color-border); border-bottom: none; - font-size: 0.875rem; + font-size: var(--font-base); } .historyItem:first-child { - border-radius: 0.5rem 0.5rem 0 0; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; } .historyItem:last-child { border-bottom: 1px solid var(--color-border); - border-radius: 0 0 0.5rem 0.5rem; + border-radius: 0 0 var(--radius-lg) var(--radius-lg); } .historyItem:only-child { - border-radius: 0.5rem; + border-radius: var(--radius-lg); border-bottom: 1px solid var(--color-border); } .historyDot { - margin-top: 0.375rem; + margin-top: var(--space-2); } .historyContent { @@ -768,60 +764,60 @@ .historyMain { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); flex-wrap: wrap; } .historyTime { color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } .historyTrigger { display: inline-block; - padding: 0.0625rem 0.375rem; + padding: 0.0625rem var(--space-2); font-size: 0.6875rem; - font-weight: 500; + font-weight: var(--font-medium); text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-radius: 3px; } .historyUser { color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } .historySummary { color: var(--color-text-muted); - font-size: 0.8125rem; - margin-top: 0.25rem; + font-size: var(--font-sm); + margin-top: var(--space-1); } .historyDuration { color: var(--color-text-muted); - font-size: 0.75rem; + font-size: var(--font-xs); } .loadMoreButton { display: block; width: 100%; - padding: 0.75rem; - margin-top: 0.5rem; - font-size: 0.875rem; - font-weight: 500; + padding: var(--space-3); + margin-top: var(--space-2); + font-size: var(--font-base); + font-weight: var(--font-medium); color: var(--color-accent); - background-color: var(--color-bg-card); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.375rem; + border-radius: var(--radius-md); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .loadMoreButton:hover:not(:disabled) { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .loadMoreButton:disabled { @@ -830,63 +826,63 @@ } .noItems { - padding: 2rem; + padding: var(--space-6); text-align: center; color: var(--color-text-muted); - background-color: var(--color-bg-hover); - border: 1px dashed var(--color-border-input); - border-radius: 0.5rem; + background-color: var(--color-surface-hover); + border: 1px dashed var(--color-border); + border-radius: var(--radius-lg); } /* Service Key Lookup */ .lookupSection { - margin-bottom: 1.5rem; + margin-bottom: var(--space-5); border: 1px solid var(--color-border); - border-radius: 0.5rem; - background-color: var(--color-bg-card); + border-radius: var(--radius-lg); + background-color: var(--color-surface); overflow: hidden; } .lookupToggle { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--space-2); width: 100%; - padding: 0.75rem 1rem; + padding: var(--space-3) var(--space-4); border: none; background: none; cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + font-size: var(--font-base); + font-weight: var(--font-medium); + color: var(--color-text); text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .lookupToggle:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .lookupHint { - font-weight: 400; - font-size: 0.8125rem; + font-weight: var(--font-normal); + font-size: var(--font-sm); color: var(--color-text-muted); margin-left: auto; } .lookupContent { border-top: 1px solid var(--color-border); - padding: 0.75rem 1rem; + padding: var(--space-3) var(--space-4); } .lookupSearch { position: relative; - margin-bottom: 0.75rem; + margin-bottom: var(--space-3); } .lookupSearchIcon { position: absolute; - left: 0.5rem; + left: var(--space-2); top: 50%; transform: translateY(-50%); color: var(--color-text-muted); @@ -895,19 +891,19 @@ .lookupSearchInput { width: 100%; - padding: 0.375rem 0.5rem 0.375rem 2rem; - font-size: 0.8125rem; - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.15s; + padding: var(--space-2) var(--space-2) var(--space-2) var(--space-6); + font-size: var(--font-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast); } .lookupSearchInput:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .lookupTable { @@ -922,22 +918,22 @@ .lookupTable th { text-align: left; - padding: 0.5rem 0.75rem; + padding: var(--space-2) var(--space-3); font-size: 0.6875rem; - font-weight: 600; + font-weight: var(--font-semibold); text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); border-bottom: 1px solid var(--color-border); position: sticky; top: 0; } .lookupTable td { - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - color: var(--color-text-primary); + padding: var(--space-2) var(--space-3); + font-size: var(--font-sm); + color: var(--color-text); border-bottom: 1px solid var(--color-border); } @@ -946,22 +942,22 @@ } .lookupTable tbody tr:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .lookupKey { display: inline-flex; align-items: center; - gap: 0.25rem; + gap: var(--space-1); } .lookupKey code { - font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; - font-size: 0.75rem; - padding: 0.0625rem 0.25rem; - background-color: var(--color-bg-hover); + font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace; + font-size: var(--font-xs); + padding: 0.0625rem var(--space-1); + background-color: var(--color-surface-hover); border: 1px solid var(--color-border); - border-radius: 0.25rem; + border-radius: var(--radius-sm); } .lookupCopy { @@ -972,27 +968,27 @@ height: 20px; padding: 0; border: 1px solid var(--color-border); - background: var(--color-bg-card); + background: var(--color-surface); color: var(--color-text-muted); cursor: pointer; border-radius: 3px; - transition: all 0.15s; + transition: all var(--duration-fast); flex-shrink: 0; } .lookupCopy:hover { - background: var(--color-bg-hover); - color: var(--color-text-primary); + background: var(--color-surface-hover); + color: var(--color-text); } .lookupCopied { - color: var(--color-success); - border-color: var(--color-success); + color: var(--color-healthy); + border-color: var(--color-healthy); } .lookupNoKey { color: var(--color-text-muted); - font-size: 0.75rem; + font-size: var(--font-xs); } .lookupTeam { @@ -1002,59 +998,55 @@ .lookupLoading { display: flex; align-items: center; - gap: 0.5rem; - padding: 1rem; + gap: var(--space-2); + padding: var(--space-4); color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } .spinnerSmall { - width: 1rem; - height: 1rem; - border: 2px solid var(--color-border); - border-top-color: var(--color-accent); - border-radius: 50%; + color: var(--color-accent); animation: spin 1s linear infinite; } .lookupError { display: flex; align-items: center; - gap: 0.5rem; - padding: 0.75rem; - font-size: 0.8125rem; - color: var(--color-error); + gap: var(--space-2); + padding: var(--space-3); + font-size: var(--font-sm); + color: var(--color-critical); background-color: var(--color-error-bg, rgba(220, 53, 69, 0.08)); - border-radius: 0.375rem; + border-radius: var(--radius-md); } .lookupRetry { - padding: 0.25rem 0.5rem; - font-size: 0.75rem; - font-weight: 500; - color: var(--color-text-primary); - background: var(--color-bg-card); - border: 1px solid var(--color-border-input); - border-radius: 0.25rem; + padding: var(--space-1) var(--space-2); + font-size: var(--font-xs); + font-weight: var(--font-medium); + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; margin-left: auto; } .lookupRetry:hover { - background: var(--color-bg-hover); + background: var(--color-surface-hover); } .lookupEmpty { - padding: 1.5rem; + padding: var(--space-5); text-align: center; - font-size: 0.8125rem; + font-size: var(--font-sm); color: var(--color-text-muted); } /* Responsive */ @media (max-width: 640px) { .container { - padding: 1rem; + padding: var(--space-4); } .configActions { @@ -1064,7 +1056,7 @@ .syncHeader { flex-direction: column; align-items: flex-start; - gap: 0.75rem; + gap: var(--space-3); } .syncSummaryGrid { diff --git a/client/src/components/pages/Manifest/ManifestPage.tsx b/client/src/components/pages/Manifest/ManifestPage.tsx index 555c96f..4084834 100644 --- a/client/src/components/pages/Manifest/ManifestPage.tsx +++ b/client/src/components/pages/Manifest/ManifestPage.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; +import { ChevronLeft, Loader2 } from 'lucide-react'; import { useParams, Link } from 'react-router-dom'; import { useAuth } from '../../../contexts/AuthContext'; import { useManifestConfig } from '../../../hooks/useManifestConfig'; @@ -69,7 +70,7 @@ function ManifestPage() { return (
-
+ Loading manifest configuration...
@@ -96,16 +97,7 @@ function ManifestPage() { return (
- - - + Back to {teamName || 'Team'} diff --git a/client/src/components/pages/Manifest/ManifestSyncResult.tsx b/client/src/components/pages/Manifest/ManifestSyncResult.tsx index c125981..104384a 100644 --- a/client/src/components/pages/Manifest/ManifestSyncResult.tsx +++ b/client/src/components/pages/Manifest/ManifestSyncResult.tsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, type ReactNode } from 'react'; +import { Plus, Minus, Equal, AlertTriangle, RefreshCw } from 'lucide-react'; import type { TeamManifestConfig, ManifestSyncResult as SyncResult, @@ -30,20 +31,20 @@ function formatSyncSummaryText(summary: ManifestSyncSummary): string { return parts.join(', '); } -const ACTION_ICONS: Record = { - created: { icon: '+', className: styles.changeCreated }, - updated: { icon: '~', className: styles.changeUpdated }, - unchanged: { icon: '=', className: styles.changeUnchanged }, - drift_flagged: { icon: '⚠', className: styles.changeDrift }, - deactivated: { icon: '×', className: styles.changeRemoved }, - deleted: { icon: '×', className: styles.changeRemoved }, +const ACTION_ICONS: Record = { + created: { icon: , className: styles.changeCreated }, + updated: { icon: , className: styles.changeUpdated }, + unchanged: { icon: , className: styles.changeUnchanged }, + drift_flagged: { icon: , className: styles.changeDrift }, + deactivated: { icon: , className: styles.changeRemoved }, + deleted: { icon: , className: styles.changeRemoved }, }; function ChangeList({ changes }: { changes: ManifestSyncChange[] }) { return (
{changes.map((change, i) => { - const { icon, className } = ACTION_ICONS[change.action] || { icon: '?', className: '' }; + const { icon, className } = ACTION_ICONS[change.action] || { icon: null, className: '' }; return (
{icon} diff --git a/client/src/components/pages/Manifest/ServiceKeyLookup.tsx b/client/src/components/pages/Manifest/ServiceKeyLookup.tsx index 03db68a..3fa93e8 100644 --- a/client/src/components/pages/Manifest/ServiceKeyLookup.tsx +++ b/client/src/components/pages/Manifest/ServiceKeyLookup.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from 'react'; +import { ChevronRight, Search, Copy, Check, Loader2 } from 'lucide-react'; import { fetchServiceCatalog } from '../../../api/services'; import type { CatalogEntry } from '../../../types/service'; import styles from './ManifestPage.module.css'; @@ -69,20 +70,13 @@ function ServiceKeyLookup() { onClick={handleToggle} aria-expanded={isExpanded} > - - - + /> Service Key Lookup Find manifest keys from other teams @@ -93,7 +87,7 @@ function ServiceKeyLookup() {
{isLoading && (
-
+ Loading catalog...
)} @@ -113,18 +107,7 @@ function ServiceKeyLookup() { {loaded && !error && ( <>
- - - - + {copiedId === entry.id ? ( - - - + ) : ( - - - - + )} From 9c8b6882e16fde233a9981f5399838eb54f6d900 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 22:02:33 -0700 Subject: [PATCH 16/21] DPS-74: Polish Login and NotFound pages with design tokens and Lucide icons --- client/src/components/Login/Login.module.css | 124 +++++++++--------- client/src/components/Login/Login.tsx | 7 +- .../components/NotFound/NotFound.module.css | 40 +++--- client/src/components/NotFound/NotFound.tsx | 2 + 4 files changed, 92 insertions(+), 81 deletions(-) diff --git a/client/src/components/Login/Login.module.css b/client/src/components/Login/Login.module.css index a5e38dd..a0642f0 100644 --- a/client/src/components/Login/Login.module.css +++ b/client/src/components/Login/Login.module.css @@ -3,126 +3,124 @@ display: flex; align-items: center; justify-content: center; - background-color: var(--color-bg-page); - padding: 1rem; + background-color: var(--color-bg); + padding: var(--space-4); } .card { - background: var(--color-bg-card); - border-radius: 8px; - box-shadow: var(--shadow-md); - padding: 2rem; + background: var(--color-surface); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); + box-shadow: var(--shadow-lg); + padding: var(--space-6); width: 100%; max-width: 400px; - border: 1px solid var(--color-border); } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-heading); + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); text-align: center; - margin-bottom: 0.5rem; + margin: 0 0 var(--space-1) 0; } .subtitle { - color: var(--color-text-secondary); + color: var(--color-text-muted); text-align: center; - margin-bottom: 2rem; + font-size: var(--font-base); + margin: 0 0 var(--space-6) 0; } .form { display: flex; flex-direction: column; - gap: 1.25rem; + gap: var(--space-4); } .field { display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--space-1); } .label { - font-size: 0.875rem; - font-weight: 500; - color: var(--color-text-primary); + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text); } .input { - padding: 0.75rem; - border: 1px solid var(--color-border-input); - border-radius: 4px; - font-size: 1rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); - transition: border-color 0.2s, box-shadow 0.2s; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-base); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease, box-shadow var(--duration-fast) ease; } .input:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .input:disabled { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); cursor: not-allowed; + opacity: 0.7; } .button { - padding: 0.75rem; - background-color: var(--color-primary); - color: var(--color-text-inverse); + padding: var(--space-2) var(--space-4); + background-color: var(--color-accent); + color: #fff; border: none; - border-radius: 4px; - font-size: 1rem; - font-weight: 500; + border-radius: var(--radius-sm); + font-size: var(--font-base); + font-weight: var(--font-medium); cursor: pointer; - transition: background-color 0.2s; - margin-top: 0.5rem; + transition: background-color var(--duration-fast) ease; + margin-top: var(--space-2); + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); } .button:hover:not(:disabled) { - background-color: var(--color-primary-hover); + background-color: var(--color-accent-hover); } .button:disabled { - background-color: var(--color-text-muted); + opacity: 0.6; cursor: not-allowed; } .error { - background-color: var(--color-error-bg); - border: 1px solid var(--color-error-border); - color: var(--color-error); - padding: 0.75rem; - border-radius: 4px; - font-size: 0.875rem; -} - -.hint { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--color-border); - font-size: 0.75rem; - color: var(--color-text-muted); -} - -.hint p { - margin-bottom: 0.5rem; + background-color: color-mix(in srgb, var(--color-critical) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-critical) 30%, transparent); + color: var(--color-critical); + padding: var(--space-3); + border-radius: var(--radius-sm); + font-size: var(--font-sm); + margin-bottom: var(--space-4); } -.hint ul { - list-style: none; - padding-left: 0; +.loading { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + color: var(--color-text-muted); + font-size: var(--font-sm); } -.hint li { - padding: 0.25rem 0; +@keyframes spin { + to { transform: rotate(360deg); } } -.hintNote { - font-style: italic; - margin-top: 0.5rem; +.spinner { + animation: spin 1s linear infinite; } diff --git a/client/src/components/Login/Login.tsx b/client/src/components/Login/Login.tsx index ba0d92e..ab05989 100644 --- a/client/src/components/Login/Login.tsx +++ b/client/src/components/Login/Login.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, FormEvent } from 'react'; +import { Loader2 } from 'lucide-react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useAuth } from '../../contexts/AuthContext'; import { fetchAuthMode, localLogin, AuthMode } from '../../api/auth'; @@ -51,7 +52,10 @@ function Login() { return (
-

Loading...

+
+ + Loading... +
); @@ -110,6 +114,7 @@ function Login() { type="submit" disabled={isSubmitting} > + {isSubmitting && } {isSubmitting ? 'Signing in...' : 'Sign In'} diff --git a/client/src/components/NotFound/NotFound.module.css b/client/src/components/NotFound/NotFound.module.css index ad88e49..0d2cd7f 100644 --- a/client/src/components/NotFound/NotFound.module.css +++ b/client/src/components/NotFound/NotFound.module.css @@ -3,7 +3,7 @@ align-items: center; justify-content: center; min-height: 60vh; - padding: 2rem; + padding: var(--space-6); } .content { @@ -13,35 +13,41 @@ .code { font-size: 6rem; font-weight: 700; - color: var(--color-text-heading); - margin-bottom: 0.5rem; + color: var(--color-text); + margin: 0 0 var(--space-2) 0; line-height: 1; } .title { - font-size: 1.5rem; - font-weight: 600; - color: var(--color-text-primary); - margin-bottom: 1rem; + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0 0 var(--space-3) 0; } .message { - color: var(--color-text-secondary); - margin-bottom: 2rem; + color: var(--color-text-muted); + font-size: var(--font-base); + margin: 0 auto var(--space-6) auto; max-width: 400px; } .link { - display: inline-block; - padding: 0.75rem 1.5rem; - background-color: var(--color-primary); - color: var(--color-text-inverse); + display: inline-flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + background: transparent; + color: var(--color-text-secondary); text-decoration: none; - border-radius: 4px; - font-weight: 500; - transition: background-color 0.2s; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: var(--font-sm); + font-weight: var(--font-medium); + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .link:hover { - background-color: var(--color-primary-hover); + background-color: var(--color-surface-hover); + color: var(--color-text); } diff --git a/client/src/components/NotFound/NotFound.tsx b/client/src/components/NotFound/NotFound.tsx index fa0f14d..f132168 100644 --- a/client/src/components/NotFound/NotFound.tsx +++ b/client/src/components/NotFound/NotFound.tsx @@ -1,3 +1,4 @@ +import { ArrowLeft } from 'lucide-react'; import { Link } from 'react-router-dom'; import styles from './NotFound.module.css'; @@ -11,6 +12,7 @@ function NotFound() { The page you are looking for does not exist or has been moved.

+ Go to Dashboard
From cd7716ba86be95027e1f5bf7af538b341629fad7 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 22:04:32 -0700 Subject: [PATCH 17/21] DPS-74: Polish SearchableSelect and StatusBadge with design tokens and Lucide icons --- .../common/SearchableSelect.module.css | 91 ++++++++++--------- .../components/common/SearchableSelect.tsx | 5 +- .../components/common/StatusBadge.module.css | 61 ++++--------- 3 files changed, 66 insertions(+), 91 deletions(-) diff --git a/client/src/components/common/SearchableSelect.module.css b/client/src/components/common/SearchableSelect.module.css index 2c30abd..69ab72f 100644 --- a/client/src/components/common/SearchableSelect.module.css +++ b/client/src/components/common/SearchableSelect.module.css @@ -2,12 +2,12 @@ position: relative; display: flex; flex-direction: column; - gap: 0.375rem; + gap: var(--space-1); } .label { - font-size: 0.8125rem; - font-weight: 600; + font-size: var(--font-sm); + font-weight: var(--font-semibold); color: var(--color-text-muted); } @@ -16,21 +16,21 @@ align-items: center; justify-content: space-between; width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; - background-color: var(--color-bg-input); - border: 1px solid var(--color-border-input); - border-radius: 0.375rem; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); cursor: pointer; - color: var(--color-text-primary); + color: var(--color-text); text-align: left; - transition: border-color 0.15s; + transition: border-color var(--duration-fast) ease; } .trigger:focus { outline: none; border-color: var(--color-accent); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); } .selectedText { @@ -44,8 +44,8 @@ } .chevron { - width: 1.25rem; - height: 1.25rem; + width: 1rem; + height: 1rem; flex-shrink: 0; color: var(--color-text-muted); } @@ -55,30 +55,31 @@ top: 100%; left: 0; right: 0; - z-index: 50; - margin-top: 0.25rem; - background-color: var(--color-bg-card); + z-index: var(--z-dropdown); + margin-top: var(--space-1); + background-color: var(--color-surface); border: 1px solid var(--color-border); - border-radius: 0.5rem; - box-shadow: var(--shadow-lg, 0 10px 15px -3px rgba(0, 0, 0, 0.1)); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); max-height: 300px; display: flex; flex-direction: column; } .searchWrapper { - padding: 0.5rem; - border-bottom: 1px solid var(--color-border); + padding: var(--space-2); + border-bottom: 1px solid var(--color-border-subtle); } .searchInput { width: 100%; - padding: 0.375rem 0.5rem; - font-size: 0.8125rem; - border: 1px solid var(--color-border-input); - border-radius: 0.25rem; - background-color: var(--color-bg-input); - color: var(--color-text-primary); + padding: var(--space-1) var(--space-2); + font-size: var(--font-sm); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background-color: var(--color-surface); + color: var(--color-text); + transition: border-color var(--duration-fast) ease; } .searchInput:focus { @@ -89,19 +90,20 @@ .clearButton { display: block; width: 100%; - padding: 0.375rem 0.75rem; - font-size: 0.75rem; + padding: var(--space-1) var(--space-3); + font-size: var(--font-xs); color: var(--color-text-muted); background: none; border: none; - border-bottom: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border-subtle); cursor: pointer; text-align: left; + transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; } .clearButton:hover { - background-color: var(--color-bg-hover); - color: var(--color-text-primary); + background-color: var(--color-surface-hover); + color: var(--color-text); } .optionsList { @@ -110,39 +112,40 @@ } .groupLabel { - padding: 0.375rem 0.75rem; - font-size: 0.6875rem; - font-weight: 600; + padding: var(--space-1) var(--space-3); + font-size: var(--font-xs); + font-weight: var(--font-semibold); text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.04em; color: var(--color-text-muted); - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .option { display: block; width: 100%; - padding: 0.5rem 0.75rem; - font-size: 0.875rem; + padding: var(--space-2) var(--space-3); + font-size: var(--font-base); text-align: left; background: none; border: none; cursor: pointer; - color: var(--color-text-primary); + color: var(--color-text); + transition: background-color var(--duration-fast) ease; } .option:hover { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); } .optionSelected { - background-color: rgba(59, 130, 246, 0.08); - font-weight: 500; + background-color: color-mix(in srgb, var(--color-accent) 8%, transparent); + font-weight: var(--font-medium); } .noResults { - padding: 1rem; + padding: var(--space-4); text-align: center; color: var(--color-text-muted); - font-size: 0.8125rem; + font-size: var(--font-sm); } diff --git a/client/src/components/common/SearchableSelect.tsx b/client/src/components/common/SearchableSelect.tsx index 3636a02..6fbb37e 100644 --- a/client/src/components/common/SearchableSelect.tsx +++ b/client/src/components/common/SearchableSelect.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +import { ChevronDown } from 'lucide-react'; import styles from './SearchableSelect.module.css'; export interface SelectOption { @@ -96,9 +97,7 @@ function SearchableSelect({ {value ? selectedLabel : placeholder} - - - + {isOpen && (
diff --git a/client/src/components/common/StatusBadge.module.css b/client/src/components/common/StatusBadge.module.css index 143a814..4b5f295 100644 --- a/client/src/components/common/StatusBadge.module.css +++ b/client/src/components/common/StatusBadge.module.css @@ -1,21 +1,21 @@ .badge { display: inline-flex; align-items: center; - gap: 0.375rem; - font-weight: 500; + gap: var(--space-1); + font-weight: var(--font-medium); border-radius: 9999px; white-space: nowrap; } .medium { - padding: 0.25rem 0.75rem; - font-size: 0.875rem; + padding: var(--space-1) var(--space-3); + font-size: var(--font-base); } .small { - padding: 0.125rem 0.5rem; - font-size: 0.75rem; - gap: 0.25rem; + padding: 2px var(--space-2); + font-size: var(--font-xs); + gap: 3px; } .dot { @@ -31,61 +31,34 @@ } .healthy { - background-color: #dcfce7; - color: #166534; -} - -[data-theme="dark"] .healthy { - background-color: #14532d; - color: #86efac; + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); } .healthy .dot { - background-color: #22c55e; -} - -[data-theme="dark"] .healthy .dot { - background-color: #34d399; + background-color: var(--color-healthy); } .warning { - background-color: #fef3c7; - color: #92400e; -} - -[data-theme="dark"] .warning { - background-color: #451a03; - color: #fde68a; + background-color: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); } .warning .dot { - background-color: #f59e0b; -} - -[data-theme="dark"] .warning .dot { - background-color: #fbbf24; + background-color: var(--color-warning); } .critical { - background-color: #fee2e2; - color: #991b1b; -} - -[data-theme="dark"] .critical { - background-color: #450a0a; - color: #fca5a5; + background-color: color-mix(in srgb, var(--color-critical) 15%, transparent); + color: var(--color-critical); } .critical .dot { - background-color: #ef4444; -} - -[data-theme="dark"] .critical .dot { - background-color: #f87171; + background-color: var(--color-critical); } .unknown { - background-color: var(--color-bg-hover); + background-color: var(--color-surface-hover); color: var(--color-text-muted); } From 9772b2d363cc22a90a836af66205685e093ab2d1 Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 22:11:28 -0700 Subject: [PATCH 18/21] =?UTF-8?q?DPS-74:=20Final=20audit=20=E2=80=94=20rep?= =?UTF-8?q?lace=20hardcoded=20values,=20transition=20durations,=20and=20da?= =?UTF-8?q?rk=20theme=20overrides=20with=20design=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Charts/HealthTimeline.module.css | 4 +- .../components/Charts/LatencyChart.module.css | 2 +- .../Charts/TimeRangeSelector.module.css | 2 +- .../common/ConfirmDialog.module.css | 2 +- .../ErrorHistoryPanel.module.css | 17 +++---- .../DependencyGraph.module.css | 8 +-- .../EdgeDetailsPanel.module.css | 32 +++--------- .../NodeDetailsPanel.module.css | 18 ++----- .../components/pages/Manifest/SyncHistory.tsx | 6 +-- .../Services/DependencyEditModal.module.css | 22 ++++----- .../pages/Services/DependencyList.module.css | 14 ++---- .../Services/PollIssuesSection.module.css | 31 +++++------- .../Services/SchemaConfigEditor.module.css | 6 +-- .../pages/Services/ServiceForm.module.css | 10 ++-- .../pages/Services/Services.module.css | 20 ++++---- .../pages/Teams/AlertChannels.module.css | 49 ++++++------------- .../pages/Teams/AlertHistory.module.css | 36 +++----------- .../pages/Teams/AlertMutes.module.css | 8 +-- .../pages/Teams/AlertRules.module.css | 29 ++++------- .../pages/Teams/ManifestStatusCard.module.css | 22 +++------ .../pages/Teams/TeamForm.module.css | 10 ++-- .../components/pages/Teams/Teams.module.css | 11 ++--- client/src/styles/shared.module.css | 2 +- 23 files changed, 124 insertions(+), 237 deletions(-) diff --git a/client/src/components/Charts/HealthTimeline.module.css b/client/src/components/Charts/HealthTimeline.module.css index e521551..2cde12f 100644 --- a/client/src/components/Charts/HealthTimeline.module.css +++ b/client/src/components/Charts/HealthTimeline.module.css @@ -65,7 +65,7 @@ color: var(--color-accent); font-size: 12px; cursor: pointer; - transition: all 0.15s; + transition: all var(--duration-fast); } .retryButton:hover { @@ -88,7 +88,7 @@ position: relative; min-width: 2px; cursor: pointer; - transition: opacity 0.15s; + transition: opacity var(--duration-fast); } .segment:hover { diff --git a/client/src/components/Charts/LatencyChart.module.css b/client/src/components/Charts/LatencyChart.module.css index 329b7aa..84662cf 100644 --- a/client/src/components/Charts/LatencyChart.module.css +++ b/client/src/components/Charts/LatencyChart.module.css @@ -65,7 +65,7 @@ color: var(--color-accent); font-size: 12px; cursor: pointer; - transition: all 0.15s; + transition: all var(--duration-fast); } .retryButton:hover { diff --git a/client/src/components/Charts/TimeRangeSelector.module.css b/client/src/components/Charts/TimeRangeSelector.module.css index 339c652..09a21e2 100644 --- a/client/src/components/Charts/TimeRangeSelector.module.css +++ b/client/src/components/Charts/TimeRangeSelector.module.css @@ -15,7 +15,7 @@ font-size: 12px; font-weight: 500; cursor: pointer; - transition: all 0.15s; + transition: all var(--duration-fast); line-height: 1.4; } diff --git a/client/src/components/common/ConfirmDialog.module.css b/client/src/components/common/ConfirmDialog.module.css index 7bc94ea..1096beb 100644 --- a/client/src/components/common/ConfirmDialog.module.css +++ b/client/src/components/common/ConfirmDialog.module.css @@ -65,5 +65,5 @@ } .confirmButton.destructive:hover:not(:disabled) { - background: #b91c1c; + background: color-mix(in srgb, var(--color-critical) 85%, black); } diff --git a/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css b/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css index fe777f1..b8b9f0d 100644 --- a/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css +++ b/client/src/components/common/ErrorHistoryPanel/ErrorHistoryPanel.module.css @@ -20,7 +20,7 @@ color: var(--color-text-primary); cursor: pointer; border-radius: 8px; - transition: all 0.15s; + transition: all var(--duration-fast); flex-shrink: 0; } @@ -100,7 +100,7 @@ font-size: 13px; font-weight: 500; cursor: pointer; - transition: background 0.15s; + transition: background var(--duration-fast); } .retryButton:hover { @@ -108,7 +108,7 @@ } .emptyState svg { - color: #10b981; + color: var(--color-healthy); } .emptyState p { @@ -194,7 +194,7 @@ } .timelineItem.recovery .timelineDot { - background: #10b981; + background: var(--color-healthy); } .timelineContent { @@ -230,13 +230,8 @@ } .recoveryStatus { - background: #ecfdf5; - color: #059669; -} - -[data-theme="dark"] .recoveryStatus { - background: #064e3b; - color: #34d399; + background: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); } .timelineMessage { diff --git a/client/src/components/pages/DependencyGraph/DependencyGraph.module.css b/client/src/components/pages/DependencyGraph/DependencyGraph.module.css index a766a95..847eb4c 100644 --- a/client/src/components/pages/DependencyGraph/DependencyGraph.module.css +++ b/client/src/components/pages/DependencyGraph/DependencyGraph.module.css @@ -398,7 +398,7 @@ min-width: 160px; max-width: 200px; box-shadow: var(--shadow-sm); - transition: all 0.15s; + transition: all var(--duration-fast); } .serviceNode:hover, @@ -888,14 +888,10 @@ .edgeLabelHighLatency { color: var(--color-warning) !important; border-color: var(--color-warning) !important; - background: #fffbeb !important; + background: color-mix(in srgb, var(--color-warning) 15%, var(--color-surface)) !important; animation: pulseLabel 1.5s ease-in-out infinite; } -[data-theme="dark"] .edgeLabelHighLatency { - background: #451a03 !important; -} - /* Pulsing legend dot for high latency */ @keyframes pulseLegendDot { 0%, 100% { diff --git a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css index 5db5a10..cfcd02a 100644 --- a/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css +++ b/client/src/components/pages/DependencyGraph/EdgeDetailsPanel.module.css @@ -107,23 +107,13 @@ } .statusBadge.healthy { - background: #ecfdf5; - color: #059669; -} - -[data-theme="dark"] .statusBadge.healthy { - background: #064e3b; - color: #34d399; + background: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); } .statusBadge.warning { - background: #fffbeb; - color: #d97706; -} - -[data-theme="dark"] .statusBadge.warning { - background: #451a03; - color: #fbbf24; + background: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); } .statusBadge.critical { @@ -137,13 +127,8 @@ } .statusBadge.highLatency { - background: #fffbeb; - color: #d97706; -} - -[data-theme="dark"] .statusBadge.highLatency { - background: #451a03; - color: #fbbf24; + background: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); } .statusDot { @@ -402,7 +387,7 @@ .errorDetails { margin: var(--space-2) 0 0; padding: var(--space-2); - background: rgba(0, 0, 0, 0.1); + background: color-mix(in srgb, var(--color-text) 8%, transparent); border-radius: var(--radius-sm); font-size: var(--font-xs); font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, monospace; @@ -413,9 +398,6 @@ overflow-y: auto; } -[data-theme="dark"] .errorDetails { - background: rgba(0, 0, 0, 0.3); -} /* Check Details Grid */ .checkDetailsGrid { diff --git a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css index 6e906da..45e5f06 100644 --- a/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css +++ b/client/src/components/pages/DependencyGraph/NodeDetailsPanel.module.css @@ -154,23 +154,13 @@ } .statusBadge.healthy { - background: #ecfdf5; - color: #059669; -} - -[data-theme="dark"] .statusBadge.healthy { - background: #064e3b; - color: #34d399; + background: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); } .statusBadge.warning { - background: #fffbeb; - color: #d97706; -} - -[data-theme="dark"] .statusBadge.warning { - background: #451a03; - color: #fbbf24; + background: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); } .statusBadge.critical { diff --git a/client/src/components/pages/Manifest/SyncHistory.tsx b/client/src/components/pages/Manifest/SyncHistory.tsx index 75b3b06..09cf440 100644 --- a/client/src/components/pages/Manifest/SyncHistory.tsx +++ b/client/src/components/pages/Manifest/SyncHistory.tsx @@ -23,7 +23,7 @@ function formatEntrySummary(entry: ManifestSyncHistoryEntry): string { if (summary.services.updated > 0) parts.push(`~${summary.services.updated}`); if (summary.services.deactivated > 0) parts.push(`-${summary.services.deactivated}`); if (summary.services.deleted > 0) parts.push(`×${summary.services.deleted}`); - if (summary.services.drift_flagged > 0) parts.push(`⚠${summary.services.drift_flagged}`); + if (summary.services.drift_flagged > 0) parts.push(`!${summary.services.drift_flagged}`); if (summary.services.unchanged > 0) parts.push(`=${summary.services.unchanged}`); return parts.join(' '); } catch { @@ -83,14 +83,14 @@ function HistoryEntry({ entry }: { entry: ManifestSyncHistoryEntry }) {
{summaryText}
)} {errors.length > 0 && ( -
+
{errors.map((e, i) => (
{e}
))}
)} {warnings.length > 0 && ( -
+
Warnings:
    {warnings.map((w, i) => ( diff --git a/client/src/components/pages/Services/DependencyEditModal.module.css b/client/src/components/pages/Services/DependencyEditModal.module.css index 33ad1c6..e8faeab 100644 --- a/client/src/components/pages/Services/DependencyEditModal.module.css +++ b/client/src/components/pages/Services/DependencyEditModal.module.css @@ -53,7 +53,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s; + transition: border-color var(--duration-fast); } .input:focus { @@ -76,7 +76,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s; + transition: border-color var(--duration-fast); } .contactKeyInput:focus { @@ -93,7 +93,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s; + transition: border-color var(--duration-fast); } .contactValueInput:focus { @@ -115,7 +115,7 @@ cursor: pointer; border-radius: 4px; flex-shrink: 0; - transition: all 0.15s; + transition: all var(--duration-fast); } .contactRemoveButton:hover { @@ -136,7 +136,7 @@ border: 1px dashed var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); align-self: flex-start; } @@ -174,7 +174,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s; + transition: border-color var(--duration-fast); } .aliasInput:focus { @@ -242,7 +242,7 @@ color: var(--color-text-muted); cursor: pointer; border-radius: 4px; - transition: all 0.15s; + transition: all var(--duration-fast); } .removeButton:hover { @@ -262,7 +262,7 @@ border: 1px dashed var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); } .addAssocButton:hover { @@ -298,7 +298,7 @@ border: 1px solid transparent; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .btnPrimary:hover:not(:disabled) { @@ -322,7 +322,7 @@ border: 1px solid var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .btnSecondary:hover:not(:disabled) { @@ -346,7 +346,7 @@ border: 1px solid var(--color-error-border, var(--color-error)); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .btnDanger:hover:not(:disabled) { diff --git a/client/src/components/pages/Services/DependencyList.module.css b/client/src/components/pages/Services/DependencyList.module.css index ae57521..92b9d35 100644 --- a/client/src/components/pages/Services/DependencyList.module.css +++ b/client/src/components/pages/Services/DependencyList.module.css @@ -66,7 +66,7 @@ background: none; cursor: pointer; text-align: left; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); gap: 1rem; } @@ -116,15 +116,11 @@ font-size: 0.625rem; font-weight: 500; border-radius: 9999px; - background-color: rgba(239, 68, 68, 0.1); - color: var(--color-error, #dc3545); + background-color: color-mix(in srgb, var(--color-critical) 12%, transparent); + color: var(--color-critical); white-space: nowrap; } -[data-theme="dark"] .mutedBadge { - background-color: rgba(239, 68, 68, 0.15); -} - .aliasBadge { display: inline-block; padding: 0.0625rem 0.375rem; @@ -241,7 +237,7 @@ color: var(--color-text-muted); cursor: pointer; border-radius: 6px; - transition: all 0.15s; + transition: all var(--duration-fast); } .editButton:hover { @@ -251,7 +247,7 @@ } .chevron { - transition: transform 0.2s ease; + transition: transform var(--duration-normal) ease; flex-shrink: 0; color: var(--color-text-muted); } diff --git a/client/src/components/pages/Services/PollIssuesSection.module.css b/client/src/components/pages/Services/PollIssuesSection.module.css index bad1569..e691c34 100644 --- a/client/src/components/pages/Services/PollIssuesSection.module.css +++ b/client/src/components/pages/Services/PollIssuesSection.module.css @@ -13,7 +13,7 @@ border: 1px solid var(--color-border); border-radius: 0.5rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .sectionToggle:hover { @@ -54,8 +54,8 @@ } .badgeWarning { - background-color: var(--color-warning-bg, #fef3c7); - color: var(--color-warning, #d97706); + background-color: color-mix(in srgb, var(--color-warning) 15%, transparent); + color: var(--color-warning); } .badgeNeutral { @@ -64,7 +64,7 @@ } .chevron { - transition: transform 0.2s ease; + transition: transform var(--duration-normal) ease; flex-shrink: 0; color: var(--color-text-muted); } @@ -129,7 +129,7 @@ border: 1px solid var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .retryButton:hover { @@ -147,7 +147,7 @@ } .emptyState svg { - color: #10b981; + color: var(--color-healthy); flex-shrink: 0; } @@ -179,18 +179,14 @@ font-size: 0.8125rem; color: var(--color-text-primary); padding: 0.5rem 0.75rem; - background: var(--color-warning-bg, #fef3c7); + background: color-mix(in srgb, var(--color-warning) 15%, transparent); border-radius: 0.375rem; } -[data-theme="dark"] .warningItem { - background: #78350f33; -} - .warningIcon { flex-shrink: 0; margin-top: 0.125rem; - color: var(--color-warning, #d97706); + color: var(--color-warning); } /* Timeline */ @@ -240,7 +236,7 @@ } .timelineItem.recovery .timelineDot { - background: #10b981; + background: var(--color-healthy); } .timelineContent { @@ -276,13 +272,8 @@ } .recoveryStatus { - background: #ecfdf5; - color: #059669; -} - -[data-theme="dark"] .recoveryStatus { - background: #064e3b; - color: #34d399; + background: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); } .timelineMessage { diff --git a/client/src/components/pages/Services/SchemaConfigEditor.module.css b/client/src/components/pages/Services/SchemaConfigEditor.module.css index 8ff81dc..a5abdc0 100644 --- a/client/src/components/pages/Services/SchemaConfigEditor.module.css +++ b/client/src/components/pages/Services/SchemaConfigEditor.module.css @@ -32,7 +32,7 @@ border: 1px solid var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); background-color: var(--color-bg-input); color: var(--color-text-primary); } @@ -85,7 +85,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .input:focus { @@ -164,7 +164,7 @@ background-color: transparent; color: var(--color-accent); cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .testButton:hover:not(:disabled) { diff --git a/client/src/components/pages/Services/ServiceForm.module.css b/client/src/components/pages/Services/ServiceForm.module.css index 9803e36..f890771 100644 --- a/client/src/components/pages/Services/ServiceForm.module.css +++ b/client/src/components/pages/Services/ServiceForm.module.css @@ -10,7 +10,7 @@ gap: 0.5rem; padding: 0.75rem 1rem; font-size: 0.875rem; - color: var(--color-warning-text, #92400e); + color: var(--color-warning); background-color: var(--color-warning-bg, rgba(245, 158, 11, 0.08)); border: 1px solid var(--color-warning-border, rgba(245, 158, 11, 0.3)); border-radius: 0.375rem; @@ -20,7 +20,7 @@ .warningIcon { flex-shrink: 0; margin-top: 0.125rem; - color: var(--color-warning-text, #92400e); + color: var(--color-warning); } .error { @@ -56,7 +56,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .input:focus, @@ -144,7 +144,7 @@ border: 1px solid var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .cancelButton:hover:not(:disabled) { @@ -165,7 +165,7 @@ border: 1px solid transparent; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .submitButton:hover:not(:disabled) { diff --git a/client/src/components/pages/Services/Services.module.css b/client/src/components/pages/Services/Services.module.css index 19e8096..06e3e22 100644 --- a/client/src/components/pages/Services/Services.module.css +++ b/client/src/components/pages/Services/Services.module.css @@ -373,7 +373,7 @@ text-decoration: none; font-size: 0.875rem; margin-bottom: 1rem; - transition: color 0.15s; + transition: color var(--duration-fast); } .backLink:hover { @@ -414,7 +414,7 @@ font-weight: 500; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); } .graphLink { @@ -716,7 +716,7 @@ color: var(--color-text-muted); cursor: pointer; border-radius: 6px; - transition: all 0.15s; + transition: all var(--duration-fast); } .historyButton:hover { @@ -746,7 +746,7 @@ font-size: 0.875rem; font-weight: 500; color: var(--color-text-primary); - transition: background-color 0.15s; + transition: background-color var(--duration-fast); text-align: left; } @@ -765,7 +765,7 @@ } .chevron { - transition: transform 0.2s ease; + transition: transform var(--duration-normal) ease; flex-shrink: 0; color: var(--color-text-muted); } @@ -835,7 +835,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s; + transition: border-color var(--duration-fast); } .overrideInput:focus { @@ -858,7 +858,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s; + transition: border-color var(--duration-fast); } .contactKeyInput:focus { @@ -875,7 +875,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s; + transition: border-color var(--duration-fast); } .contactValueInput:focus { @@ -897,7 +897,7 @@ cursor: pointer; border-radius: 4px; flex-shrink: 0; - transition: all 0.15s; + transition: all var(--duration-fast); } .contactRemoveButton:hover { @@ -918,7 +918,7 @@ border: 1px dashed var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s, border-color 0.15s; + transition: background-color var(--duration-fast), border-color var(--duration-fast); align-self: flex-start; } diff --git a/client/src/components/pages/Teams/AlertChannels.module.css b/client/src/components/pages/Teams/AlertChannels.module.css index 6558274..7ec88e4 100644 --- a/client/src/components/pages/Teams/AlertChannels.module.css +++ b/client/src/components/pages/Teams/AlertChannels.module.css @@ -17,7 +17,7 @@ border: none; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .addChannelButton:hover { @@ -102,7 +102,7 @@ border: 1px solid var(--color-accent); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .updateUrlButton:hover:not(:disabled) { @@ -130,7 +130,7 @@ border: 1px solid var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .cancelButton:hover:not(:disabled) { @@ -151,7 +151,7 @@ border: none; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .submitButton:hover:not(:disabled) { @@ -297,13 +297,8 @@ } .statusActive { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .statusActive { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); } .statusInactive { @@ -325,24 +320,14 @@ font-weight: 500; border-radius: 0.25rem; cursor: pointer; - transition: background-color 0.15s; - color: #1e40af; - background-color: #dbeafe; - border: 1px solid #93c5fd; -} - -[data-theme="dark"] .testButton { - color: #93c5fd; - background-color: #1e3a5f; - border-color: #1e3a5f; + transition: background-color var(--duration-fast); + color: var(--color-accent); + background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); } .testButton:hover:not(:disabled) { - background-color: #bfdbfe; -} - -[data-theme="dark"] .testButton:hover:not(:disabled) { - background-color: #264a6f; + background-color: color-mix(in srgb, var(--color-accent) 22%, transparent); } .testButton:disabled { @@ -357,18 +342,12 @@ justify-content: space-between; padding: 0.5rem 0.75rem; font-size: 0.875rem; - color: #166534; - background-color: #dcfce7; - border: 1px solid #86efac; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); border-radius: 0.375rem; } -[data-theme="dark"] .testSuccess { - color: #86efac; - background-color: #14532d; - border-color: #166534; -} - .testFailure { display: flex; align-items: center; diff --git a/client/src/components/pages/Teams/AlertHistory.module.css b/client/src/components/pages/Teams/AlertHistory.module.css index 571d9fb..89e1bd6 100644 --- a/client/src/components/pages/Teams/AlertHistory.module.css +++ b/client/src/components/pages/Teams/AlertHistory.module.css @@ -30,43 +30,23 @@ } .status_sent { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .status_sent { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); } .status_failed { - color: #991b1b; - background-color: #fee2e2; -} - -[data-theme="dark"] .status_failed { - color: #fca5a5; - background-color: #7f1d1d; + color: var(--color-critical); + background-color: color-mix(in srgb, var(--color-critical) 15%, transparent); } .status_suppressed { - color: #92400e; - background-color: #fef3c7; -} - -[data-theme="dark"] .status_suppressed { - color: #fcd34d; - background-color: #78350f; + color: var(--color-warning); + background-color: color-mix(in srgb, var(--color-warning) 15%, transparent); } .status_muted { - color: #6b7280; - background-color: #f3f4f6; -} - -[data-theme="dark"] .status_muted { - color: #9ca3af; - background-color: #374151; + color: var(--color-unknown); + background-color: color-mix(in srgb, var(--color-unknown) 15%, transparent); } /* Cell Styles */ diff --git a/client/src/components/pages/Teams/AlertMutes.module.css b/client/src/components/pages/Teams/AlertMutes.module.css index 5519878..ae8ccdc 100644 --- a/client/src/components/pages/Teams/AlertMutes.module.css +++ b/client/src/components/pages/Teams/AlertMutes.module.css @@ -128,15 +128,15 @@ .deleteButton { padding: 0.25rem 0.5rem; font-size: 0.75rem; - color: var(--color-error, #dc3545); + color: var(--color-critical); background: none; - border: 1px solid var(--color-error, #dc3545); + border: 1px solid var(--color-critical); border-radius: 0.25rem; cursor: pointer; } .deleteButton:hover { - background-color: var(--color-error, #dc3545); + background-color: var(--color-critical); color: white; } @@ -153,7 +153,7 @@ } .error { - color: var(--color-error, #dc3545); + color: var(--color-critical); font-size: 0.875rem; margin-bottom: 0.75rem; } diff --git a/client/src/components/pages/Teams/AlertRules.module.css b/client/src/components/pages/Teams/AlertRules.module.css index 2569dd1..263c8d3 100644 --- a/client/src/components/pages/Teams/AlertRules.module.css +++ b/client/src/components/pages/Teams/AlertRules.module.css @@ -64,11 +64,11 @@ height: 1.25rem; border-radius: 9999px; padding: 0.125rem; - transition: background-color 0.2s; + transition: background-color var(--duration-normal); } .toggleActive .toggleTrack { - background-color: #22c55e; + background-color: var(--color-healthy); } .toggleInactive .toggleTrack { @@ -81,7 +81,7 @@ height: 1rem; border-radius: 50%; background-color: white; - transition: transform 0.2s; + transition: transform var(--duration-normal); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); } @@ -185,7 +185,7 @@ border: none; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .saveButton:hover:not(:disabled) { @@ -204,18 +204,12 @@ justify-content: space-between; padding: 0.5rem 0.75rem; font-size: 0.875rem; - color: #166534; - background-color: #dcfce7; - border: 1px solid #86efac; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); border-radius: 0.375rem; } -[data-theme="dark"] .saveSuccess { - color: #86efac; - background-color: #14532d; - border-color: #166534; -} - .dismissButton { background: none; border: none; @@ -266,13 +260,8 @@ } .rulesStatusActive { - color: #166534; - background-color: #dcfce7; -} - -[data-theme="dark"] .rulesStatusActive { - color: #86efac; - background-color: #14532d; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); } .rulesStatusInactive { diff --git a/client/src/components/pages/Teams/ManifestStatusCard.module.css b/client/src/components/pages/Teams/ManifestStatusCard.module.css index 9f9057d..56605c9 100644 --- a/client/src/components/pages/Teams/ManifestStatusCard.module.css +++ b/client/src/components/pages/Teams/ManifestStatusCard.module.css @@ -36,11 +36,11 @@ } .statusDotSuccess { - background-color: #22c55e; + background-color: var(--color-healthy); } .statusDotPartial { - background-color: #eab308; + background-color: var(--color-warning); } .statusDotError { @@ -72,7 +72,7 @@ border: 1px solid var(--color-warning-border); border-radius: 0.375rem; text-decoration: none; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .driftAlert:hover { @@ -106,15 +106,9 @@ } .syncResultSuccess { - color: #166534; - background-color: #dcfce7; - border: 1px solid #86efac; -} - -[data-theme="dark"] .syncResultSuccess { - color: #86efac; - background-color: #14532d; - border-color: #166534; + color: var(--color-healthy); + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); + border: 1px solid color-mix(in srgb, var(--color-healthy) 30%, transparent); } .syncResultError { @@ -169,7 +163,7 @@ border: none; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .syncButton:hover:not(:disabled) { @@ -192,7 +186,7 @@ text-decoration: none; border: 1px solid var(--color-border-input); border-radius: 0.375rem; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .manageLink:hover { diff --git a/client/src/components/pages/Teams/TeamForm.module.css b/client/src/components/pages/Teams/TeamForm.module.css index a2e7d92..e27af84 100644 --- a/client/src/components/pages/Teams/TeamForm.module.css +++ b/client/src/components/pages/Teams/TeamForm.module.css @@ -38,7 +38,7 @@ border-radius: 0.375rem; background-color: var(--color-bg-input); color: var(--color-text-primary); - transition: border-color 0.15s, box-shadow 0.15s; + transition: border-color var(--duration-fast), box-shadow var(--duration-fast); } .input:focus, @@ -91,7 +91,7 @@ border: 1px solid var(--color-border-input); border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .cancelButton:hover:not(:disabled) { @@ -112,7 +112,7 @@ border: 1px solid transparent; border-radius: 0.375rem; cursor: pointer; - transition: background-color 0.15s; + transition: background-color var(--duration-fast); } .submitButton:hover:not(:disabled) { @@ -177,7 +177,7 @@ border-radius: 0.25rem; color: var(--color-text-muted); cursor: pointer; - transition: color 0.15s, border-color 0.15s; + transition: color var(--duration-fast), border-color var(--duration-fast); } .contactRemoveButton:hover:not(:disabled) { @@ -199,7 +199,7 @@ border: 1px dashed var(--color-border-input); border-radius: 0.25rem; cursor: pointer; - transition: border-color 0.15s, background-color 0.15s; + transition: border-color var(--duration-fast), background-color var(--duration-fast); align-self: flex-start; } diff --git a/client/src/components/pages/Teams/Teams.module.css b/client/src/components/pages/Teams/Teams.module.css index 893f082..e342564 100644 --- a/client/src/components/pages/Teams/Teams.module.css +++ b/client/src/components/pages/Teams/Teams.module.css @@ -346,7 +346,7 @@ } .dangerButton:hover:not(:disabled) { - background: #b91c1c; + background: color-mix(in srgb, var(--color-critical) 80%, black); } .dangerButton:disabled { @@ -402,13 +402,8 @@ } .roleLead { - color: #1e40af; - background-color: #dbeafe; -} - -[data-theme="dark"] .roleLead { - color: #93c5fd; - background-color: #1e3a5f; + color: var(--color-accent); + background-color: color-mix(in srgb, var(--color-accent) 15%, transparent); } .roleMember { diff --git a/client/src/styles/shared.module.css b/client/src/styles/shared.module.css index 07789f1..abce61a 100644 --- a/client/src/styles/shared.module.css +++ b/client/src/styles/shared.module.css @@ -120,7 +120,7 @@ background: var(--color-critical); } .buttonDanger:hover { - background: #b91c1c; + background: color-mix(in srgb, var(--color-critical) 85%, black); } /* --- Loading --- */ From ba8a351ca5a990450835d1976412d9d4bfe8908e Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 22:13:41 -0700 Subject: [PATCH 19/21] DPS-74: Update client architecture spec and README with design tokens, Tabs component, and tabbed views --- README.md | 2 +- docs/spec/10-client-architecture.md | 68 +++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 75929ca..d4d3435 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ For detailed deployment options (bare Node.js, reverse proxy, backups), see the ## Tech Stack -- **Frontend:** React 18, TypeScript, Vite, CSS Modules, React Flow, Recharts +- **Frontend:** React 18, TypeScript, Vite, CSS Modules (design tokens + `color-mix()`), Lucide React icons, Inter font, React Flow, Recharts - **Backend:** Express.js, TypeScript, SQLite (better-sqlite3) - **Authentication:** OpenID Connect (openid-client) or local auth (bcryptjs) - **Testing:** Jest, React Testing Library diff --git a/docs/spec/10-client-architecture.md b/docs/spec/10-client-architecture.md index 1dd5125..5528268 100644 --- a/docs/spec/10-client-architecture.md +++ b/docs/spec/10-client-architecture.md @@ -9,9 +9,9 @@ | `/login` | Login | Public | OIDC redirect or local auth form | | `/` | Dashboard | Protected | Health summary overview | | `/services` | ServicesList | Protected | Searchable, filterable service list | -| `/services/:id` | ServiceDetail | Protected | Dependencies, latency, errors, contact info, override indicators, inline override editing (team lead+/admin), manual poll, inline alias management (admin) | +| `/services/:id` | ServiceDetail | Protected | Tabbed view (`?tab=`): Overview (metadata, actions), Dependencies (list + detail modal), Dependent Reports (table), Poll Issues. Inline override editing (team lead+/admin), manual poll, inline alias management (admin) | | `/teams` | TeamsList | Protected | Team listing with counts | -| `/teams/:id` | TeamDetail | Protected | Members, roles, owned services | +| `/teams/:id` | TeamDetail | Protected | Tabbed view (`?tab=`): Overview (info, edit/delete), Members (add/remove/promote), Manifests (ManifestStatusCard), Services (list), Alerts Config (channels, rules, mutes, history) | | `/graph` | DependencyGraph | Protected | Interactive React Flow visualization | | `/associations` | Associations | Protected | Manual association creation, aliases, canonical override management (team lead+/admin) | | `/wallboard` | Wallboard | Protected | Full-screen status board with dependency detail panel showing resolved contact info and impact with override indicators | @@ -71,8 +71,66 @@ All API modules follow a consistent pattern: | `useAliases` | Global alias management — CRUD + canonical names list. | | `useCanonicalOverrides` | Canonical override CRUD — load all, save (upsert), remove, lookup by canonical name. | | `useAlertRules` | Loads alert rules for a team. Handles save (upsert) with dirty tracking. Exposes `rules`, `save`, `error`, `isSaving`. | +| `useManifestConfig` | Loads/saves manifest configuration for a team. Handles toggle, sync trigger, sync result state. | +| `useSyncHistory` | Loads paginated sync history for a team manifest. Supports load-more. | +| `useDriftReview` | Loads drift flags for a team. Handles accept, dismiss, reopen, and bulk actions. | -## 10.5 Client-Side Storage +## 10.5 Common Components + +### Tabs + +Reusable tabbed navigation component (`client/src/components/common/Tabs.tsx`). + +```tsx + + + Overview + Members + + ... + ... + +``` + +- Tab state is reflected in URL search params (`?tab=members`) for linkability +- Falls back to `localStorage` persistence via `storageKey` prop +- Active tab indicator uses a 2px accent-colored bottom border +- Used by TeamDetail and ServiceDetail pages + +### DependencyDetailModal + +Modal for viewing dependency details from the ServiceDetail Dependencies tab (`client/src/components/pages/Services/DependencyDetailModal.tsx`). Shows health status, latency chart, contact info, and override section. + +## 10.6 Design Token System + +The client uses a structured CSS custom property system defined in `client/src/index.css`. All component styles reference these tokens — no hardcoded color, spacing, or timing values. + +**Typography:** Inter (body + headings) loaded from Google Fonts with `font-display: swap`. Monospace stack for code/data values. + +**Token categories:** +- **Spacing** (theme-independent): `--space-1` (4px) through `--space-8` (64px), 8px base grid +- **Typography** (theme-independent): `--font-xs` through `--font-2xl`, weight tokens (`--font-normal`, `--font-medium`, `--font-semibold`) +- **Border radius** (theme-independent): `--radius-sm` (4px), `--radius-md` (6px), `--radius-lg` (8px) +- **Transitions** (theme-independent): `--duration-fast` (150ms), `--duration-normal` (200ms), `--duration-slow` (300ms) +- **Surface colors** (theme-dependent): `--color-bg`, `--color-surface`, `--color-surface-hover`, `--color-border`, `--color-border-subtle` +- **Text colors** (theme-dependent): `--color-text`, `--color-text-secondary`, `--color-text-muted` +- **Status colors** (same for both themes): `--color-healthy`, `--color-warning`, `--color-critical`, `--color-unknown` +- **Accent** (same for both themes): `--color-accent`, `--color-accent-hover` +- **Shadows** (theme-dependent): `--shadow-sm`, `--shadow-md`, `--shadow-lg` + +**Status badge pattern:** Uses `color-mix()` for theme-adaptive tinted backgrounds: +```css +.healthy { + background-color: color-mix(in srgb, var(--color-healthy) 15%, transparent); + color: var(--color-healthy); +} +``` + +**Shared CSS module classes:** `client/src/styles/shared.module.css` provides composable base classes (`.card`, `.buttonPrimary`, `.buttonGhost`, `.input`, `.tableRow`, etc.) used via CSS Modules `composes:` syntax. + +**Icons:** All icons use Lucide React SVG components — no emoji or inline SVG. + +## 10.7 Client-Side Storage | Key | Scope | Content | |---|---|---| @@ -84,8 +142,10 @@ All API modules follow a consistent pattern: | `{page}-refresh-interval` | Per page | Interval in ms | | `wallboard-team-filter` | Wallboard | Selected team ID | | `wallboard-unhealthy-only` | Wallboard | `'true'` or `'false'` | +| `team-{id}-tab` | Per team | Last active tab on TeamDetail | +| `service-{id}-tab` | Per service | Last active tab on ServiceDetail | -## 10.6 High Latency Detection +## 10.8 High Latency Detection The dependency graph automatically flags edges as "high latency" using an adaptive threshold with an absolute floor. No user configuration is required. From 6b24658a4beb23abccd016d1a73d5b745dbf875d Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 22:43:47 -0700 Subject: [PATCH 20/21] UI polish pass --- .../components/common/SummaryCards.module.css | 174 ++++++++++++++++++ .../pages/Dashboard/Dashboard.module.css | 155 +--------------- .../pages/Dashboard/Dashboard.test.tsx | 2 +- .../components/pages/Dashboard/Dashboard.tsx | 67 +++---- .../src/components/pages/Teams/TeamDetail.tsx | 6 + .../pages/Teams/TeamOverviewStats.test.tsx | 146 +++++++++++++++ .../pages/Teams/TeamOverviewStats.tsx | 157 ++++++++++++++++ .../components/pages/Teams/Teams.module.css | 12 ++ client/src/hooks/useTeamServiceHealth.test.ts | 155 ++++++++++++++++ client/src/hooks/useTeamServiceHealth.ts | 60 ++++++ client/vite.config.js | 2 +- client/vite.config.ts | 2 +- 12 files changed, 749 insertions(+), 189 deletions(-) create mode 100644 client/src/components/common/SummaryCards.module.css create mode 100644 client/src/components/pages/Teams/TeamOverviewStats.test.tsx create mode 100644 client/src/components/pages/Teams/TeamOverviewStats.tsx create mode 100644 client/src/hooks/useTeamServiceHealth.test.ts create mode 100644 client/src/hooks/useTeamServiceHealth.ts diff --git a/client/src/components/common/SummaryCards.module.css b/client/src/components/common/SummaryCards.module.css new file mode 100644 index 0000000..2667020 --- /dev/null +++ b/client/src/components/common/SummaryCards.module.css @@ -0,0 +1,174 @@ +/* ============================================================= + SummaryCards — Shared summary card & health bar primitives + Used by Dashboard, TeamOverviewStats, and other stat displays + ============================================================= */ + +/* --- Summary Cards Grid --- */ + +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--space-4); +} + +.summaryCard { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-2); + border-left: 3px solid var(--color-unknown); + transition: background-color var(--duration-fast) ease; +} + +.summaryCard:hover { + background: var(--color-surface-hover); +} + +.summaryCardAccent { + composes: summaryCard; + border-left-color: var(--color-accent); +} + +.summaryCardHealthy { + composes: summaryCard; + border-left-color: var(--color-healthy); +} + +.summaryCardWarning { + composes: summaryCard; + border-left-color: var(--color-warning); +} + +.summaryCardCritical { + composes: summaryCard; + border-left-color: var(--color-critical); +} + +.cardLabel { + font-size: var(--font-xs); + font-weight: var(--font-medium); + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted); +} + +.cardValue { + font-size: var(--font-2xl); + font-weight: var(--font-semibold); + font-variant-numeric: tabular-nums; + color: var(--color-text); + line-height: var(--line-height-tight); +} + +.cardSubtext { + font-size: var(--font-xs); + color: var(--color-text-muted); +} + +/* --- Health Overview Bar --- */ + +.healthOverview { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-4); +} + +.healthOverviewHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--space-3); +} + +.healthOverviewTitle { + font-size: var(--font-base); + font-weight: var(--font-semibold); + color: var(--color-text); + margin: 0; +} + +.healthOverviewSubtitle { + font-size: var(--font-sm); + font-weight: var(--font-medium); + color: var(--color-text-muted); +} + +.healthBar { + display: flex; + height: 1rem; + overflow: hidden; + background-color: var(--color-border-subtle); +} + +.healthSegment { + transition: width var(--duration-slow) ease; + min-width: 2px; +} + +.segmentHealthy { + background-color: var(--color-healthy); +} + +.segmentWarning { + background-color: var(--color-warning); +} + +.segmentCritical { + background-color: var(--color-critical); +} + +.segmentUnknown { + background-color: var(--color-unknown); + opacity: 0.4; +} + +.healthLegend { + display: flex; + gap: var(--space-4); + margin-top: var(--space-2); +} + +.healthLegendItem { + display: flex; + align-items: center; + gap: var(--space-1); + font-size: var(--font-xs); + color: var(--color-text-muted); +} + +.healthLegendDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +/* --- Skeleton for loading state --- */ + +.skeletonCard { + background: var(--color-border-subtle); + border-radius: var(--radius-sm); + animation: summaryPulse 1.5s ease-in-out infinite; +} + +.skeletonSummaryCard { + composes: skeletonCard; + height: 96px; +} + +@keyframes summaryPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* --- Responsive --- */ + +@media (max-width: 640px) { + .cardValue { + font-size: var(--font-xl); + } +} diff --git a/client/src/components/pages/Dashboard/Dashboard.module.css b/client/src/components/pages/Dashboard/Dashboard.module.css index 88b2ecc..5471a18 100644 --- a/client/src/components/pages/Dashboard/Dashboard.module.css +++ b/client/src/components/pages/Dashboard/Dashboard.module.css @@ -170,151 +170,12 @@ .areaUnstable { grid-area: unstable; } .areaPolling { grid-area: polling; } -/* --- Summary Cards Grid --- */ +/* --- Summary Cards (shared styles in SummaryCards.module.css) --- */ -.summaryGrid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--space-4); -} - -.summaryCard { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); - display: flex; - flex-direction: column; - gap: var(--space-2); - border-left: 3px solid var(--color-unknown); - transition: background-color var(--duration-fast) ease; -} - -.summaryCard:hover { - background: var(--color-surface-hover); -} - -.summaryCardTotal { - composes: summaryCard; - border-left-color: var(--color-accent); +.summaryCardClickable { cursor: pointer; } -.summaryCardHealthy { - composes: summaryCard; - border-left-color: var(--color-healthy); -} - -.summaryCardWarning { - composes: summaryCard; - border-left-color: var(--color-warning); -} - -.summaryCardCritical { - composes: summaryCard; - border-left-color: var(--color-critical); -} - -.cardLabel { - font-size: var(--font-xs); - font-weight: var(--font-medium); - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-muted); -} - -.cardValue { - font-size: var(--font-2xl); - font-weight: var(--font-semibold); - font-variant-numeric: tabular-nums; - color: var(--color-text); - line-height: var(--line-height-tight); -} - -.cardSubtext { - font-size: var(--font-xs); - color: var(--color-text-muted); -} - -/* --- Health Overview --- */ - -.healthOverview { - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: var(--space-4); -} - -.healthOverviewHeader { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--space-3); -} - -.healthOverviewTitle { - font-size: var(--font-base); - font-weight: var(--font-semibold); - color: var(--color-text); - margin: 0; -} - -.healthOverviewSubtitle { - font-size: var(--font-sm); - font-weight: var(--font-medium); - color: var(--color-text-muted); -} - -.healthBar { - display: flex; - height: 1rem; - overflow: hidden; - background-color: var(--color-border-subtle); -} - -.healthSegment { - transition: width var(--duration-slow) ease; - min-width: 2px; -} - -.segmentHealthy { - background-color: var(--color-healthy); -} - -.segmentWarning { - background-color: var(--color-warning); -} - -.segmentCritical { - background-color: var(--color-critical); -} - -.segmentUnknown { - background-color: var(--color-unknown); - opacity: 0.4; -} - -.healthLegend { - display: flex; - gap: var(--space-4); - margin-top: var(--space-2); -} - -.healthLegendItem { - display: flex; - align-items: center; - gap: var(--space-1); - font-size: var(--font-xs); - color: var(--color-text-muted); -} - -.healthLegendDot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} - /* --- Section Cards --- */ .section { @@ -823,10 +684,6 @@ /* --- Responsive --- */ @media (max-width: 1024px) { - .summaryGrid { - grid-template-columns: repeat(2, 1fr); - } - .dashboard, .skeletonDashboard { grid-template-columns: 1fr; @@ -846,10 +703,6 @@ padding: var(--space-4); } - .summaryGrid { - grid-template-columns: 1fr; - } - .header { flex-direction: column; align-items: flex-start; @@ -860,8 +713,4 @@ width: 100%; justify-content: flex-end; } - - .cardValue { - font-size: var(--font-xl); - } } diff --git a/client/src/components/pages/Dashboard/Dashboard.test.tsx b/client/src/components/pages/Dashboard/Dashboard.test.tsx index 837bc9d..ef3a650 100644 --- a/client/src/components/pages/Dashboard/Dashboard.test.tsx +++ b/client/src/components/pages/Dashboard/Dashboard.test.tsx @@ -234,7 +234,7 @@ describe('Dashboard', () => { }); // Find and click the clickable total card - const card = screen.getByText('Total Services').closest('[class*="summaryCardTotal"]'); + const card = screen.getByText('Total Services').closest('[class*="summaryCardClickable"]'); fireEvent.click(card!); expect(mockNavigate).toHaveBeenCalledWith('/services'); diff --git a/client/src/components/pages/Dashboard/Dashboard.tsx b/client/src/components/pages/Dashboard/Dashboard.tsx index a2d1e75..828e2db 100644 --- a/client/src/components/pages/Dashboard/Dashboard.tsx +++ b/client/src/components/pages/Dashboard/Dashboard.tsx @@ -5,6 +5,7 @@ import { formatRelativeTime } from '../../../utils/formatting'; import { getHealthBadgeStatus } from '../../../utils/statusMapping'; import { usePolling, INTERVAL_OPTIONS } from '../../../hooks/usePolling'; import { useDashboard } from '../../../hooks/useDashboard'; +import cardStyles from '../../common/SummaryCards.module.css'; import styles from './Dashboard.module.css'; function Dashboard() { @@ -117,88 +118,88 @@ function Dashboard() { {/* Dashboard Grid */}
    {/* Summary Cards */} -
    +
    navigate('/services')} > - Total Services - {stats.total} - {teams.length} teams + Total Services + {stats.total} + {teams.length} teams
    -
    - Healthy - {stats.healthy} - +
    + Healthy + {stats.healthy} + {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% of services
    -
    - Warning - {stats.warning} - need attention +
    + Warning + {stats.warning} + need attention
    -
    - Critical - {stats.critical} - require action +
    + Critical + {stats.critical} + require action
    {/* Health Overview Bar */} -
    -
    -

    Health Overview

    - +
    +
    +

    Health Overview

    + {stats.total > 0 ? Math.round((stats.healthy / stats.total) * 100) : 0}% healthy
    {stats.total > 0 ? ( <> -
    +
    {stats.healthy > 0 && (
    )} {stats.warning > 0 && (
    )} {stats.critical > 0 && (
    )} {stats.total - stats.healthy - stats.warning - stats.critical > 0 && (
    )}
    -
    - - +
    + + Healthy ({stats.healthy}) {stats.warning > 0 && ( - - + + Warning ({stats.warning}) )} {stats.critical > 0 && ( - - + + Critical ({stats.critical}) )} diff --git a/client/src/components/pages/Teams/TeamDetail.tsx b/client/src/components/pages/Teams/TeamDetail.tsx index 1904de2..1b9bd72 100644 --- a/client/src/components/pages/Teams/TeamDetail.tsx +++ b/client/src/components/pages/Teams/TeamDetail.tsx @@ -12,6 +12,7 @@ import AlertChannels from './AlertChannels'; import AlertRules from './AlertRules'; import AlertHistory from './AlertHistory'; import AlertMutes from './AlertMutes'; +import TeamOverviewStats from './TeamOverviewStats'; import ManifestStatusCard from './ManifestStatusCard'; import { useAlertChannels } from '../../../hooks/useAlertChannels'; import styles from './Teams.module.css'; @@ -185,6 +186,11 @@ function TeamDetail() {
    )}
    + {/* Members Tab */} diff --git a/client/src/components/pages/Teams/TeamOverviewStats.test.tsx b/client/src/components/pages/Teams/TeamOverviewStats.test.tsx new file mode 100644 index 0000000..235a8c1 --- /dev/null +++ b/client/src/components/pages/Teams/TeamOverviewStats.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import TeamOverviewStats from './TeamOverviewStats'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +function jsonResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + }; +} + +const mockMembers = [ + { team_id: 't1', user_id: 'u1', role: 'lead' as const, created_at: '', user: { id: 'u1', email: 'a@test.com', name: 'Alice', role: 'admin', is_active: 1 } }, + { team_id: 't1', user_id: 'u2', role: 'member' as const, created_at: '', user: { id: 'u2', email: 'b@test.com', name: 'Bob', role: 'user', is_active: 1 } }, + { team_id: 't1', user_id: 'u3', role: 'member' as const, created_at: '', user: { id: 'u3', email: 'c@test.com', name: 'Carol', role: 'user', is_active: 1 } }, +]; + +const mockTeamServices = [ + { id: 's1', name: 'Svc A', team_id: 't1', health_endpoint: '/health', metrics_endpoint: null, is_active: 1, manifest_managed: 1, created_at: '', updated_at: '' }, + { id: 's2', name: 'Svc B', team_id: 't1', health_endpoint: '/health', metrics_endpoint: null, is_active: 1, manifest_managed: 0, created_at: '', updated_at: '' }, + { id: 's3', name: 'Svc C', team_id: 't1', health_endpoint: '/health', metrics_endpoint: null, is_active: 0, manifest_managed: 0, created_at: '', updated_at: '' }, +]; + +const mockServicesWithHealth = [ + { + id: 's1', name: 'Svc A', team_id: 't1', + health: { status: 'healthy', healthy_reports: 5, warning_reports: 0, critical_reports: 0, total_reports: 5, dependent_count: 1 }, + dependencies: [{ id: 'd1' }, { id: 'd2' }], + }, + { + id: 's2', name: 'Svc B', team_id: 't1', + health: { status: 'warning', healthy_reports: 3, warning_reports: 2, critical_reports: 0, total_reports: 5, dependent_count: 0 }, + dependencies: [{ id: 'd3' }], + }, + { + id: 's3', name: 'Svc C', team_id: 't1', + health: { status: 'critical', healthy_reports: 0, warning_reports: 0, critical_reports: 5, total_reports: 5, dependent_count: 0 }, + dependencies: [], + }, +]; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('TeamOverviewStats', () => { + it('renders member count and role breakdown', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth)); + + render( + + ); + + expect(screen.getByText('Members')).toBeInTheDocument(); + expect(screen.getByText('1 lead · 2 members')).toBeInTheDocument(); + // Verify member count appears in the Members card + const membersLabel = screen.getByText('Members'); + const membersCard = membersLabel.closest('div'); + expect(membersCard?.querySelector('[class*="cardValue"]')?.textContent).toBe('3'); + }); + + it('renders service count with active/inactive and manifest breakdown', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth)); + + render( + + ); + + expect(screen.getByText('Services')).toBeInTheDocument(); + expect(screen.getByText('2 active · 1 inactive · 1 manifest-managed')).toBeInTheDocument(); + }); + + it('renders health stats after loading', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth)); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Service Health')).toBeInTheDocument(); + }); + + expect(screen.getByText('1 healthy · 1 warning · 1 critical')).toBeInTheDocument(); + }); + + it('renders dependency count after loading', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth)); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Dependencies')).toBeInTheDocument(); + }); + + expect(screen.getByText('across 3 services')).toBeInTheDocument(); + }); + + it('renders health bar when services have health data', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(mockServicesWithHealth)); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Health Overview')).toBeInTheDocument(); + }); + + expect(screen.getByRole('img', { name: /team health distribution/i })).toBeInTheDocument(); + expect(screen.getByText(/Healthy \(1\)/)).toBeInTheDocument(); + expect(screen.getByText(/Warning \(1\)/)).toBeInTheDocument(); + expect(screen.getByText(/Critical \(1\)/)).toBeInTheDocument(); + }); + + it('shows error message on fetch failure', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Failed to load health data')).toBeInTheDocument(); + }); + }); + + it('does not render health bar when no services', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse([])); + + render( + + ); + + await waitFor(() => { + expect(screen.getByText('Dependencies')).toBeInTheDocument(); + }); + + expect(screen.queryByText('Health Overview')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/pages/Teams/TeamOverviewStats.tsx b/client/src/components/pages/Teams/TeamOverviewStats.tsx new file mode 100644 index 0000000..3929a37 --- /dev/null +++ b/client/src/components/pages/Teams/TeamOverviewStats.tsx @@ -0,0 +1,157 @@ +import { useEffect, useMemo } from 'react'; +import { useTeamServiceHealth } from '../../../hooks/useTeamServiceHealth'; +import type { TeamMember, TeamService } from '../../../types/team'; +import cardStyles from '../../common/SummaryCards.module.css'; +import styles from './Teams.module.css'; + +interface TeamOverviewStatsProps { + teamId: string; + members: TeamMember[]; + services: TeamService[]; +} + +function TeamOverviewStats({ teamId, members, services }: TeamOverviewStatsProps) { + const { stats, isLoading, error, reload } = useTeamServiceHealth(teamId); + + useEffect(() => { + reload(); + }, [reload]); + + const memberBreakdown = useMemo(() => { + const leads = members.filter(m => m.role === 'lead').length; + const regular = members.length - leads; + const parts: string[] = []; + if (leads > 0) parts.push(`${leads} lead${leads !== 1 ? 's' : ''}`); + if (regular > 0) parts.push(`${regular} member${regular !== 1 ? 's' : ''}`); + return parts.join(' · ') || 'no members'; + }, [members]); + + const serviceBreakdown = useMemo(() => { + const active = services.filter(s => s.is_active).length; + const inactive = services.length - active; + const manifest = services.filter(s => s.manifest_managed === 1).length; + const parts: string[] = []; + parts.push(`${active} active`); + if (inactive > 0) parts.push(`${inactive} inactive`); + if (manifest > 0) parts.push(`${manifest} manifest-managed`); + return parts.join(' · '); + }, [services]); + + const healthPercent = stats.total > 0 + ? Math.round((stats.healthy / stats.total) * 100) + : 0; + + return ( +
    +
    + {/* Members Card */} +
    + Members + {members.length} + {memberBreakdown} +
    + + {/* Services Card */} +
    + Services + {services.length} + {serviceBreakdown} +
    + + {/* Health Card */} + {isLoading ? ( +
    + ) : ( +
    + Service Health + {stats.healthy} + + {stats.healthy} healthy + {stats.warning > 0 && ` · ${stats.warning} warning`} + {stats.critical > 0 && ` · ${stats.critical} critical`} + +
    + )} + + {/* Dependencies Card */} + {isLoading ? ( +
    + ) : ( +
    + Dependencies + {stats.totalDependencies} + across {stats.total} services +
    + )} +
    + + {/* Health Bar */} + {!isLoading && stats.total > 0 && ( +
    +
    +

    Health Overview

    + + {healthPercent}% healthy + +
    +
    + {stats.healthy > 0 && ( +
    + )} + {stats.warning > 0 && ( +
    + )} + {stats.critical > 0 && ( +
    + )} + {stats.unknown > 0 && ( +
    + )} +
    +
    + + + Healthy ({stats.healthy}) + + {stats.warning > 0 && ( + + + Warning ({stats.warning}) + + )} + {stats.critical > 0 && ( + + + Critical ({stats.critical}) + + )} +
    +
    + )} + + {error && ( +
    + Failed to load health data +
    + )} +
    + ); +} + +export default TeamOverviewStats; diff --git a/client/src/components/pages/Teams/Teams.module.css b/client/src/components/pages/Teams/Teams.module.css index e342564..917dbc3 100644 --- a/client/src/components/pages/Teams/Teams.module.css +++ b/client/src/components/pages/Teams/Teams.module.css @@ -246,6 +246,18 @@ justify-content: space-between; align-items: flex-start; gap: var(--space-4); + margin-bottom: var(--space-4); +} + +.overviewStatsWrapper { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.healthError { + font-size: var(--font-xs); + color: var(--color-text-muted); } .teamTitle { diff --git a/client/src/hooks/useTeamServiceHealth.test.ts b/client/src/hooks/useTeamServiceHealth.test.ts new file mode 100644 index 0000000..5cdf3e7 --- /dev/null +++ b/client/src/hooks/useTeamServiceHealth.test.ts @@ -0,0 +1,155 @@ +import { renderHook, act } from '@testing-library/react'; +import { useTeamServiceHealth } from './useTeamServiceHealth'; + +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +function jsonResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(data), + }; +} + +const mockServices = [ + { + id: 's1', + name: 'Service A', + health: { status: 'healthy', healthy_reports: 5, warning_reports: 0, critical_reports: 0, total_reports: 5, dependent_count: 2 }, + dependencies: [{ id: 'd1' }, { id: 'd2' }], + }, + { + id: 's2', + name: 'Service B', + health: { status: 'warning', healthy_reports: 3, warning_reports: 2, critical_reports: 0, total_reports: 5, dependent_count: 1 }, + dependencies: [{ id: 'd3' }], + }, + { + id: 's3', + name: 'Service C', + health: { status: 'critical', healthy_reports: 1, warning_reports: 1, critical_reports: 3, total_reports: 5, dependent_count: 0 }, + dependencies: [], + }, + { + id: 's4', + name: 'Service D', + health: { status: 'unknown', healthy_reports: 0, warning_reports: 0, critical_reports: 0, total_reports: 0, dependent_count: 0 }, + dependencies: [{ id: 'd4' }, { id: 'd5' }, { id: 'd6' }], + }, +]; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe('useTeamServiceHealth', () => { + it('starts in loading state', () => { + mockFetch.mockImplementation(() => new Promise(() => {})); + + const { result } = renderHook(() => useTeamServiceHealth('t1')); + + expect(result.current.isLoading).toBe(true); + expect(result.current.error).toBeNull(); + expect(result.current.stats).toEqual({ + total: 0, + healthy: 0, + warning: 0, + critical: 0, + unknown: 0, + totalDependencies: 0, + }); + }); + + it('computes health stats and dependency count after loading', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(mockServices)); + + const { result } = renderHook(() => useTeamServiceHealth('t1')); + + await act(async () => { + await result.current.reload(); + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/services?team_id=t1'), + expect.any(Object) + ); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.stats).toEqual({ + total: 4, + healthy: 1, + warning: 1, + critical: 1, + unknown: 1, + totalDependencies: 6, + }); + }); + + it('handles fetch errors', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'Not found' }, 404)); + + const { result } = renderHook(() => useTeamServiceHealth('t1')); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeTruthy(); + }); + + it('handles network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network failure')); + + const { result } = renderHook(() => useTeamServiceHealth('t1')); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe('Network failure'); + }); + + it('handles services with no dependencies array', async () => { + const servicesNoDeps = [ + { + id: 's1', + name: 'Service A', + health: { status: 'healthy', healthy_reports: 5, warning_reports: 0, critical_reports: 0, total_reports: 5, dependent_count: 0 }, + }, + ]; + mockFetch.mockResolvedValueOnce(jsonResponse(servicesNoDeps)); + + const { result } = renderHook(() => useTeamServiceHealth('t1')); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.stats.totalDependencies).toBe(0); + }); + + it('reloads data on subsequent calls', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse(mockServices)); + + const { result } = renderHook(() => useTeamServiceHealth('t1')); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.stats.total).toBe(4); + + const updatedServices = [mockServices[0]]; + mockFetch.mockResolvedValueOnce(jsonResponse(updatedServices)); + + await act(async () => { + await result.current.reload(); + }); + + expect(result.current.stats.total).toBe(1); + expect(result.current.stats.healthy).toBe(1); + }); +}); diff --git a/client/src/hooks/useTeamServiceHealth.ts b/client/src/hooks/useTeamServiceHealth.ts new file mode 100644 index 0000000..cab2841 --- /dev/null +++ b/client/src/hooks/useTeamServiceHealth.ts @@ -0,0 +1,60 @@ +import { useState, useCallback, useMemo } from 'react'; +import { fetchServices } from '../api/services'; +import type { ServiceWithDependencies } from '../types/service'; + +export interface TeamServiceHealthStats { + total: number; + healthy: number; + warning: number; + critical: number; + unknown: number; + totalDependencies: number; +} + +export interface UseTeamServiceHealthReturn { + stats: TeamServiceHealthStats; + isLoading: boolean; + error: string | null; + reload: () => Promise; +} + +export function useTeamServiceHealth(teamId: string): UseTeamServiceHealthReturn { + const [services, setServices] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const reload = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const data = await fetchServices(teamId); + setServices(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load service health'); + } finally { + setIsLoading(false); + } + }, [teamId]); + + const stats = useMemo((): TeamServiceHealthStats => { + const healthy = services.filter(s => s.health.status === 'healthy').length; + const warning = services.filter(s => s.health.status === 'warning').length; + const critical = services.filter(s => s.health.status === 'critical').length; + const unknown = services.length - healthy - warning - critical; + const totalDependencies = services.reduce( + (sum, s) => sum + (s.dependencies?.length ?? 0), + 0 + ); + + return { + total: services.length, + healthy, + warning, + critical, + unknown, + totalDependencies, + }; + }, [services]); + + return { stats, isLoading, error, reload }; +} diff --git a/client/vite.config.js b/client/vite.config.js index 57e5c11..38d32a6 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -1,6 +1,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -import pkg from './package.json'; +import pkg from '../package.json'; export default defineConfig({ plugins: [react()], define: { diff --git a/client/vite.config.ts b/client/vite.config.ts index e3ff0f4..3a877b0 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -import pkg from './package.json'; +import pkg from '../package.json'; export default defineConfig({ plugins: [react()], From 8be65e672b36df7400294cc0042a8ac3b97809ed Mon Sep 17 00:00:00 2001 From: Dan Essig Date: Thu, 5 Mar 2026 22:43:53 -0700 Subject: [PATCH 21/21] 1.11.1 --- docs/depsera-logo.svg | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/depsera-logo.svg b/docs/depsera-logo.svg index 485a437..ab8c22f 100644 --- a/docs/depsera-logo.svg +++ b/docs/depsera-logo.svg @@ -1001,7 +1001,7 @@ all-encompassing visibility across your service world - v1.11.0 + v1.11.1 apache 2.0 diff --git a/package-lock.json b/package-lock.json index 83c53a5..288219f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "depsera", - "version": "1.11.0", + "version": "1.11.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "depsera", - "version": "1.11.0", + "version": "1.11.1", "license": "MIT", "devDependencies": { "concurrently": "^8.2.2", diff --git a/package.json b/package.json index bcdcc14..5982975 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "depsera", - "version": "1.11.0", + "version": "1.11.1", "description": "Dependency monitoring and service health dashboard", "scripts": { "dev": "concurrently --kill-others \"npm run dev:server\" \"npm run dev:client\"",