diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..b718035 --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,42 @@ +module.exports = { + root: true, + env: { + browser: true, + es2022: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:vue/vue3-recommended', + ], + parser: 'vue-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser', + ecmaVersion: 'latest', + sourceType: 'module', + }, + plugins: ['@typescript-eslint', 'vue'], + rules: { + // TypeScript + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/ban-ts-comment': 'warn', + + // Vue + 'vue/multi-word-component-names': 'off', + 'vue/no-v-html': 'off', + 'vue/require-default-prop': 'off', + 'vue/max-attributes-per-line': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/html-closing-bracket-newline': 'off', + 'vue/html-self-closing': 'off', + 'vue/html-indent': 'off', + 'vue/v-slot-style': 'off', + + // General + 'no-console': ['warn', { allow: ['warn', 'error'] }], + 'prefer-const': 'error', + }, + ignorePatterns: ['dist', 'node_modules', 'src/generated', '*.d.ts'], +} diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 8eddc1b..c53b864 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -11,12 +11,15 @@ declare module 'vue' { BuildServerStats: typeof import('./src/components/BuildServerStats.vue')['default'] BuildStats: typeof import('./src/components/MainNav/BuildStats.vue')['default'] CurrentlyBuilding: typeof import('./src/components/CurrentlyBuilding.vue')['default'] + EmptyState: typeof import('./src/components/common/EmptyState.vue')['default'] MainNav: typeof import('./src/components/MainNav.vue')['default'] + PackageCard: typeof import('./src/components/Packages/PackageCard.vue')['default'] PackageFilters: typeof import('./src/components/Packages/PackageFilters.vue')['default'] Packages: typeof import('./src/components/Packages.vue')['default'] PackageTable: typeof import('./src/components/Packages/PackageTable.vue')['default'] QueuedPackagesList: typeof import('./src/components/CurrentlyBuilding/QueuedPackagesList.vue')['default'] StatItem: typeof import('./src/components/MainNav/BuildStats/StatItem.vue')['default'] StatsListSection: typeof import('./src/components/MainNav/BuildStats/StatsListSection.vue')['default'] + StatusBadge: typeof import('./src/components/common/StatusBadge.vue')['default'] } } diff --git a/frontend/index.html b/frontend/index.html index 40c00e7..26ffc6e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + ALHP Status diff --git a/frontend/package.json b/frontend/package.json index 9d1c0f8..00c0ef3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,12 @@ "prebuild": "npm run generate-api-types", "dev": "node --no-warnings ./node_modules/.bin/vite", "build": "vue-tsc --noEmit && vite build", - "preview": "vite preview" + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "lint:check": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@fontsource/roboto": "^5.2.5", @@ -20,8 +25,15 @@ }, "devDependencies": { "@babel/types": "^7.27.0", + "@testing-library/vue": "^8.1.0", "@types/node": "^22.14.1", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", "@vitejs/plugin-vue": "^5.2.3", + "@vue/test-utils": "^2.4.6", + "eslint": "^8.57.0", + "eslint-plugin-vue": "^9.31.0", + "happy-dom": "^15.11.7", "openapi-typescript": "^7.6.1", "prettier": "^3.5.3", "sass": "^1.86.3", @@ -30,6 +42,7 @@ "unplugin-vue-components": "^28.5.0", "vite": "^6.2.6", "vite-plugin-vuetify": "^2.1.1", + "vitest": "^2.1.8", "vue-tsc": "^2.2.8" }, "packageManager": "yarn@4.7.0" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index cc5f32f..579a15e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,14 +1,14 @@ @@ -18,31 +18,19 @@ import BuildServerStats from '@/components/BuildServerStats.vue' import CurrentlyBuilding from '@/components/CurrentlyBuilding.vue' import Packages from '@/components/Packages.vue' import { useStatsStore } from '@/stores/statsStore' -import { onBeforeMount, onUnmounted } from 'vue' +import { onBeforeMount } from 'vue' import { usePackagesStore } from '@/stores' +import { useAutoRefresh } from '@/composables/useAutoRefresh' const statsStore = useStatsStore() const packagesStore = usePackagesStore() -let refreshInterval: number | null = null -const startAutoRefresh = (intervalMinutes = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5) => { - stopAutoRefresh() - refreshInterval = window.setInterval( - () => { - statsStore.fetchStats() - packagesStore.fetchPackages() - packagesStore.fetchCurrentlyBuilding() - }, - intervalMinutes * 60 * 1000 - ) -} - -const stopAutoRefresh = () => { - if (refreshInterval !== null) { - clearInterval(refreshInterval) - refreshInterval = null - } -} +const intervalMinutes = Number(import.meta.env.VITE_UPDATE_INTERVAL) || 5 +const { start: startAutoRefresh } = useAutoRefresh(() => { + statsStore.fetchStats() + packagesStore.fetchPackages() + packagesStore.fetchCurrentlyBuilding() +}, intervalMinutes * 60 * 1000) onBeforeMount(() => { statsStore.fetchStats() @@ -50,8 +38,32 @@ onBeforeMount(() => { packagesStore.fetchCurrentlyBuilding() startAutoRefresh() }) - -onUnmounted(() => { - stopAutoRefresh() -}) + + diff --git a/frontend/src/__tests__/composables/useAutoRefresh.spec.ts b/frontend/src/__tests__/composables/useAutoRefresh.spec.ts new file mode 100644 index 0000000..41896ca --- /dev/null +++ b/frontend/src/__tests__/composables/useAutoRefresh.spec.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { useAutoRefresh, useNowTimer } from '@/composables/useAutoRefresh' + +// Mock Vue's onUnmounted +vi.mock('vue', async () => { + const actual = await vi.importActual('vue') + return { + ...actual, + onUnmounted: vi.fn(), + } +}) + +describe('useAutoRefresh', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('useAutoRefresh', () => { + it('should call callback at specified interval', () => { + const callback = vi.fn() + const { start } = useAutoRefresh(callback, 1000) + + start() + expect(callback).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1000) + expect(callback).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(1000) + expect(callback).toHaveBeenCalledTimes(2) + }) + + it('should stop calling callback after stop()', () => { + const callback = vi.fn() + const { start, stop } = useAutoRefresh(callback, 1000) + + start() + vi.advanceTimersByTime(1000) + expect(callback).toHaveBeenCalledTimes(1) + + stop() + vi.advanceTimersByTime(2000) + expect(callback).toHaveBeenCalledTimes(1) // Still 1 + }) + + it('should track running state', () => { + const callback = vi.fn() + const { start, stop, isRunning } = useAutoRefresh(callback, 1000) + + expect(isRunning.value).toBe(false) + + start() + expect(isRunning.value).toBe(true) + + stop() + expect(isRunning.value).toBe(false) + }) + + it('should restart interval on restart()', () => { + const callback = vi.fn() + const { start, restart } = useAutoRefresh(callback, 1000) + + start() + vi.advanceTimersByTime(500) + + restart() + vi.advanceTimersByTime(500) + expect(callback).not.toHaveBeenCalled() // Timer was reset + + vi.advanceTimersByTime(500) + expect(callback).toHaveBeenCalledTimes(1) + }) + }) + + describe('useNowTimer', () => { + it('should update now value every second', () => { + const { now, start } = useNowTimer() + const initialValue = now.value + + start() + + vi.advanceTimersByTime(1000) + expect(now.value).toBeGreaterThan(initialValue) + + vi.advanceTimersByTime(1000) + expect(now.value).toBeGreaterThan(initialValue + 1) + }) + + it('should stop updating after stop()', () => { + const { now, start, stop } = useNowTimer() + + start() + vi.advanceTimersByTime(1000) + const valueAfterStart = now.value + + stop() + vi.advanceTimersByTime(2000) + expect(now.value).toBe(valueAfterStart) + }) + }) +}) diff --git a/frontend/src/__tests__/composables/useDateFormat.spec.ts b/frontend/src/__tests__/composables/useDateFormat.spec.ts new file mode 100644 index 0000000..05a1d3e --- /dev/null +++ b/frontend/src/__tests__/composables/useDateFormat.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest' +import { unixTimestampToLocalizedDate, useRelativeTime } from '@/composables/useDateFormat' + +describe('useDateFormat', () => { + describe('unixTimestampToLocalizedDate', () => { + it('should convert unix timestamp to localized date string', () => { + // Mock navigator.language + vi.stubGlobal('navigator', { language: 'en-US' }) + + const timestamp = 1702612991 // 2023-12-15T03:43:11Z + const result = unixTimestampToLocalizedDate(timestamp) + + // The exact format depends on locale, but it should be a string + expect(typeof result).toBe('string') + expect(result.length).toBeGreaterThan(0) + }) + + it('should handle timestamp 0', () => { + const result = unixTimestampToLocalizedDate(0) + expect(typeof result).toBe('string') + }) + }) + + describe('useRelativeTime', () => { + it('should format seconds correctly', () => { + const { formatTimeAgo } = useRelativeTime() + + const result = formatTimeAgo(30) + expect(result).toContain('30') + expect(result).toContain('second') + }) + + it('should format minutes correctly', () => { + const { formatTimeAgo } = useRelativeTime() + + const result = formatTimeAgo(120) // 2 minutes + expect(result).toContain('2') + expect(result).toContain('minute') + }) + + it('should format hours correctly', () => { + const { formatTimeAgo } = useRelativeTime() + + const result = formatTimeAgo(7200) // 2 hours + expect(result).toContain('2') + expect(result).toContain('hour') + }) + + it('should use minutes for values between 60 and 3600 seconds', () => { + const { formatTimeAgo } = useRelativeTime() + + const result = formatTimeAgo(180) // 3 minutes + expect(result).toContain('minute') + expect(result).not.toContain('second') + }) + + it('should use hours for values >= 3600 seconds', () => { + const { formatTimeAgo } = useRelativeTime() + + const result = formatTimeAgo(3600) // 1 hour + expect(result).toContain('hour') + expect(result).not.toContain('minute') + }) + }) +}) diff --git a/frontend/src/__tests__/composables/useDebounce.spec.ts b/frontend/src/__tests__/composables/useDebounce.spec.ts new file mode 100644 index 0000000..a59aa00 --- /dev/null +++ b/frontend/src/__tests__/composables/useDebounce.spec.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { ref, nextTick } from 'vue' +import { useDebouncedRef, useDebounce } from '@/composables/useDebounce' + +describe('useDebounce', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('useDebouncedRef', () => { + it('should debounce ref value changes', async () => { + const sourceRef = ref('initial') + const { debouncedValue } = useDebouncedRef(sourceRef, 300) + + expect(debouncedValue.value).toBe('initial') + + sourceRef.value = 'updated' + await nextTick() // Allow Vue watcher to trigger + expect(debouncedValue.value).toBe('initial') // Not yet updated + + vi.advanceTimersByTime(200) + expect(debouncedValue.value).toBe('initial') // Still not updated + + vi.advanceTimersByTime(100) + expect(debouncedValue.value).toBe('updated') // Now updated after 300ms + }) + + it('should cancel pending update when new value is set', async () => { + const sourceRef = ref('initial') + const { debouncedValue } = useDebouncedRef(sourceRef, 300) + + sourceRef.value = 'first' + await nextTick() + vi.advanceTimersByTime(200) + + sourceRef.value = 'second' + await nextTick() + vi.advanceTimersByTime(200) + + expect(debouncedValue.value).toBe('initial') // Neither update applied yet + + vi.advanceTimersByTime(100) + expect(debouncedValue.value).toBe('second') // Only second value applied + }) + + it('should allow manual cancellation', async () => { + const sourceRef = ref('initial') + const { debouncedValue, cancel } = useDebouncedRef(sourceRef, 300) + + sourceRef.value = 'updated' + await nextTick() + vi.advanceTimersByTime(200) + + cancel() + + vi.advanceTimersByTime(200) + expect(debouncedValue.value).toBe('initial') // Cancelled, no update + }) + }) + + describe('useDebounce', () => { + it('should debounce function calls', () => { + const callback = vi.fn() + const { debouncedFn } = useDebounce(callback, 300) + + debouncedFn() + expect(callback).not.toHaveBeenCalled() + + vi.advanceTimersByTime(300) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should pass arguments to callback', () => { + const callback = vi.fn() + const { debouncedFn } = useDebounce(callback, 300) + + debouncedFn('arg1', 'arg2') + vi.advanceTimersByTime(300) + + expect(callback).toHaveBeenCalledWith('arg1', 'arg2') + }) + + it('should only call callback once for rapid calls', () => { + const callback = vi.fn() + const { debouncedFn } = useDebounce(callback, 300) + + debouncedFn() + debouncedFn() + debouncedFn() + + vi.advanceTimersByTime(300) + expect(callback).toHaveBeenCalledTimes(1) + }) + + it('should allow manual cancellation', () => { + const callback = vi.fn() + const { debouncedFn, cancel } = useDebounce(callback, 300) + + debouncedFn() + vi.advanceTimersByTime(200) + cancel() + vi.advanceTimersByTime(200) + + expect(callback).not.toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/__tests__/composables/usePackageDisplay.spec.ts b/frontend/src/__tests__/composables/usePackageDisplay.spec.ts new file mode 100644 index 0000000..0fdca56 --- /dev/null +++ b/frontend/src/__tests__/composables/usePackageDisplay.spec.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { usePackageDisplay } from '@/composables/Packages/usePackageDisplay' + +describe('usePackageDisplay', () => { + const { repoName, repoVersion, getVersionColor, getStatusColor, getLto, getDs } = + usePackageDisplay() + + describe('repoName', () => { + it('should extract repo name from full repo string', () => { + expect(repoName('extra-x86_64-v3')).toBe('extra') + expect(repoName('core-x86_64-v4')).toBe('core') + }) + }) + + describe('repoVersion', () => { + it('should extract version from full repo string', () => { + expect(repoVersion('extra-x86_64-v3')).toBe('v3') + expect(repoVersion('core-x86_64-v4')).toBe('v4') + }) + }) + + describe('getVersionColor', () => { + it('should return CSS variable for v2', () => { + expect(getVersionColor('v2')).toBe('var(--color-version-v2)') + }) + + it('should return CSS variable for v3', () => { + expect(getVersionColor('v3')).toBe('var(--color-version-v3)') + }) + + it('should return CSS variable for v4', () => { + expect(getVersionColor('v4')).toBe('var(--color-version-v4)') + }) + + it('should return muted color for unknown version', () => { + expect(getVersionColor('v1')).toBe('var(--color-text-muted)') + expect(getVersionColor('unknown')).toBe('var(--color-text-muted)') + }) + }) + + describe('getStatusColor', () => { + it('should return empty string (status colors now handled by StatusBadge)', () => { + expect(getStatusColor('skipped')).toBe('') + expect(getStatusColor('queued')).toBe('') + expect(getStatusColor('latest')).toBe('') + expect(getStatusColor('failed')).toBe('') + expect(getStatusColor('signing')).toBe('') + expect(getStatusColor('building')).toBe('') + expect(getStatusColor('unknown')).toBe('') + }) + + it('should return empty string for undefined status', () => { + expect(getStatusColor(undefined)).toBe('') + }) + }) + + describe('getLto', () => { + it('should return success info for enabled', () => { + const result = getLto('enabled') + expect(result.title).toBe('Built with LTO') + expect(result.icon).toBe('mdi-check-circle') + expect(result.color).toBe('var(--color-status-success)') + }) + + it('should return waiting info for unknown', () => { + const result = getLto('unknown') + expect(result.title).toBe('Not built with LTO yet') + expect(result.icon).toBe('mdi-timer-sand') + expect(result.color).toBe('var(--color-text-muted)') + }) + + it('should return error info for disabled', () => { + const result = getLto('disabled') + expect(result.title).toBe('LTO explicitly disabled') + expect(result.icon).toBe('mdi-close-circle') + expect(result.color).toBe('var(--color-status-error)') + }) + + it('should return warning info for auto_disabled', () => { + const result = getLto('auto_disabled') + expect(result.title).toBe('LTO automatically disabled') + expect(result.icon).toBe('mdi-alert-circle') + expect(result.color).toBe('var(--color-status-warning)') + }) + + it('should return empty for undefined', () => { + const result = getLto(undefined) + expect(result.title).toBe('') + expect(result.icon).toBe('') + expect(result.color).toBe('') + }) + }) + + describe('getDs', () => { + it('should return success info for available', () => { + const result = getDs('available') + expect(result.title).toBe('Debug symbols available') + expect(result.icon).toBe('mdi-check-circle') + expect(result.color).toBe('var(--color-status-success)') + }) + + it('should return waiting info for unknown', () => { + const result = getDs('unknown') + expect(result.title).toBe('Not built yet') + expect(result.icon).toBe('mdi-timer-sand') + expect(result.color).toBe('var(--color-text-muted)') + }) + + it('should return error info for not_available', () => { + const result = getDs('not_available') + expect(result.title).toBe('Not built with debug symbols') + expect(result.icon).toBe('mdi-close-circle') + expect(result.color).toBe('var(--color-status-error)') + }) + + it('should return empty for undefined', () => { + const result = getDs(undefined) + expect(result.title).toBe('') + expect(result.icon).toBe('') + expect(result.color).toBe('') + }) + }) +}) diff --git a/frontend/src/assets/styles/base.scss b/frontend/src/assets/styles/base.scss index e5ff37c..824adf0 100644 --- a/frontend/src/assets/styles/base.scss +++ b/frontend/src/assets/styles/base.scss @@ -1,2 +1,3 @@ @use "@fontsource/roboto"; @use "fork-awesome/css/fork-awesome.min.css"; +@use "./tokens.scss"; diff --git a/frontend/src/assets/styles/tokens.scss b/frontend/src/assets/styles/tokens.scss new file mode 100644 index 0000000..ea00b6d --- /dev/null +++ b/frontend/src/assets/styles/tokens.scss @@ -0,0 +1,256 @@ +// Design Tokens - CSS Custom Properties +// These tokens form the foundation of our design system + +:root { + // ============================================ + // Color Palette - Dark Theme (Default) + // ============================================ + + // Background colors + --color-bg-primary: #0f1419; + --color-bg-secondary: #1a1f2e; + --color-bg-tertiary: #242b3d; + --color-bg-elevated: #2d3548; + --color-bg-hover: #353d52; + + // Surface colors + --color-surface: #1a1f2e; + --color-surface-variant: #242b3d; + + // Brand colors + --color-brand-primary: #3b82f6; + --color-brand-accent: #609926; + + // Status colors (improved contrast) + --color-status-success: #22c55e; + --color-status-warning: #f59e0b; + --color-status-error: #ef4444; + --color-status-info: #3b82f6; + --color-status-neutral: #6b7280; + + // Status background colors (subtle) + --color-status-success-bg: rgba(34, 197, 94, 0.15); + --color-status-warning-bg: rgba(245, 158, 11, 0.15); + --color-status-error-bg: rgba(239, 68, 68, 0.15); + --color-status-info-bg: rgba(59, 130, 246, 0.15); + --color-status-neutral-bg: rgba(107, 114, 128, 0.15); + + // Package status colors + --color-pkg-latest: #22c55e; + --color-pkg-built: #3b82f6; + --color-pkg-failed: #ef4444; + --color-pkg-skipped: #6b7280; + --color-pkg-delayed: #f59e0b; + --color-pkg-queued: #f97316; + --color-pkg-building: #14b8a6; + --color-pkg-signing: #6366f1; + --color-pkg-unknown: #9ca3af; + + // Version colors + --color-version-v2: #60a5fa; + --color-version-v3: #fbbf24; + --color-version-v4: #34d399; + + // Text colors + --color-text-primary: #f9fafb; + --color-text-secondary: #9ca3af; + --color-text-muted: #6b7280; + --color-text-inverse: #111827; + + // Border colors + --color-border: #374151; + --color-border-light: #4b5563; + + // ============================================ + // Spacing Scale (8px base) + // ============================================ + --space-0: 0; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + + // ============================================ + // Border Radius + // ============================================ + --radius-none: 0; + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + --radius-2xl: 24px; + --radius-full: 9999px; + + // ============================================ + // Shadows + // ============================================ + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.3); + + // ============================================ + // Transitions + // ============================================ + --transition-fast: 150ms ease; + --transition-normal: 250ms ease; + --transition-slow: 350ms ease; + + // ============================================ + // Typography + // ============================================ + --font-family: 'Roboto', sans-serif; + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; + + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + + --line-height-tight: 1.25; + --line-height-normal: 1.5; + --line-height-relaxed: 1.75; + + // ============================================ + // Safe Area Insets (for notched devices) + // ============================================ + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); +} + +// ============================================ +// Light Theme Override +// ============================================ +[data-theme="light"] { + // Background colors + --color-bg-primary: #ffffff; + --color-bg-secondary: #f9fafb; + --color-bg-tertiary: #f3f4f6; + --color-bg-elevated: #ffffff; + --color-bg-hover: #e5e7eb; + + // Surface colors + --color-surface: #ffffff; + --color-surface-variant: #f3f4f6; + + // Text colors + --color-text-primary: #111827; + --color-text-secondary: #4b5563; + --color-text-muted: #9ca3af; + --color-text-inverse: #f9fafb; + + // Border colors + --color-border: #e5e7eb; + --color-border-light: #d1d5db; + + // Status background colors (adjusted for light theme) + --color-status-success-bg: rgba(34, 197, 94, 0.1); + --color-status-warning-bg: rgba(245, 158, 11, 0.1); + --color-status-error-bg: rgba(239, 68, 68, 0.1); + --color-status-info-bg: rgba(59, 130, 246, 0.1); + --color-status-neutral-bg: rgba(107, 114, 128, 0.1); + + // Shadows (lighter for light theme) + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1); +} + +// ============================================ +// Global Styles +// ============================================ + +// Theme transition class - only apply transitions during theme switch +html.theme-transition, +html.theme-transition *, +html.theme-transition *::before, +html.theme-transition *::after { + transition: background-color var(--transition-normal), + border-color var(--transition-normal), + color var(--transition-fast) !important; +} + +// Focus states +:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; +} + +// Selection color +::selection { + background-color: var(--color-brand-primary); + color: var(--color-text-inverse); +} + +// Scrollbar styling (WebKit) +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--color-bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--color-border-light); + border-radius: var(--radius-full); + + &:hover { + background: var(--color-text-muted); + } +} + +// Firefox scrollbar +* { + scrollbar-width: thin; + scrollbar-color: var(--color-border-light) var(--color-bg-secondary); +} + +// ============================================ +// Vuetify Tooltip Override +// ============================================ +.v-tooltip > .v-overlay__content { + background: var(--color-bg-elevated) !important; + color: var(--color-text-primary) !important; + border: 1px solid var(--color-border) !important; + border-radius: var(--radius-md) !important; + padding: var(--space-2) var(--space-3) !important; + font-size: var(--font-size-sm) !important; + box-shadow: var(--shadow-lg) !important; +} + +[data-theme="light"] .v-tooltip > .v-overlay__content { + background: #1f2937 !important; + color: #f9fafb !important; + border-color: #374151 !important; +} + +// ============================================ +// Reduced Motion Support (accessibility) +// ============================================ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/frontend/src/components/BuildServerStats.vue b/frontend/src/components/BuildServerStats.vue index 06ec68d..16f0798 100644 --- a/frontend/src/components/BuildServerStats.vue +++ b/frontend/src/components/BuildServerStats.vue @@ -1,12 +1,19 @@