diff --git a/vue/package-lock.json b/vue/package-lock.json index 05afe2a..365e397 100644 --- a/vue/package-lock.json +++ b/vue/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "test1", "version": "0.0.0", + "license": "ISC", "dependencies": { "@element-plus/icons-vue": "^2.3.2", "vue": "^3.5.25" @@ -17,6 +18,7 @@ "@vue/tsconfig": "^0.8.1", "typescript": "~5.9.3", "vite": "^7.3.1", + "vitest": "^4.0.18", "vue-tsc": "^3.1.5" } }, @@ -880,6 +882,31 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -914,6 +941,127 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.27", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.27.tgz", @@ -1085,6 +1233,26 @@ "dev": true, "license": "MIT" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1103,6 +1271,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1151,6 +1326,16 @@ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1218,6 +1403,17 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -1225,6 +1421,13 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1317,6 +1520,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1326,6 +1536,37 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -1343,6 +1584,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1439,6 +1690,84 @@ } } }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -1483,6 +1812,23 @@ "peerDependencies": { "typescript": ">=5.0.0" } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } } } } diff --git a/vue/package.json b/vue/package.json index 460abf8..7ffbda2 100644 --- a/vue/package.json +++ b/vue/package.json @@ -13,7 +13,8 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", @@ -25,6 +26,7 @@ "@vue/tsconfig": "^0.8.1", "typescript": "~5.9.3", "vite": "^7.3.1", + "vitest": "^4.0.18", "vue-tsc": "^3.1.5" } } diff --git a/vue/src/App.vue b/vue/src/App.vue index 632da29..58e9b02 100644 --- a/vue/src/App.vue +++ b/vue/src/App.vue @@ -13,6 +13,8 @@ import { Trophy, } from '@element-plus/icons-vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from 'vue' +import { isRectVisible } from './lighting' +import { calculateRevealRadius, resolveInitialTheme, toggleTheme, type Theme } from './theme' type SectionId = 'overview' | 'explorer' | 'games' | 'school' type ExplorerItemKind = 'folder' | 'file' @@ -47,6 +49,19 @@ interface ExplorerFolderTreeItem { expanded: boolean } +interface SectionMeta { + eyebrow: string + title: string + description: string + badge: string +} + +interface LightingTarget { + element: HTMLElement + left: number + top: number +} + const text = { skip: '跳到主要内容', loginTitle: 'Workspace Login', @@ -59,6 +74,14 @@ const text = { welcome: '欢迎回来', } +const THEME_STORAGE_KEY = 'workspace-theme' +const THEME_REVEAL_DURATION_MS = 520 +const THEME_APPLY_OFFSET_MS = 320 +const THEME_CANVAS_COLORS: Record = { + light: '#f5f8fc', + dark: '#070d16', +} + const username = ref('') const password = ref('') const loginError = ref('') @@ -81,12 +104,30 @@ const sidebarIndicatorStyle = ref({ opacity: '0', }) const sidebarIndicatorJelly = ref(false) +const theme = ref('light') +const followsSystemTheme = ref(true) +const themeRevealActive = ref(false) +const themeRevealStyle = ref>({ + '--reveal-x': '50vw', + '--reveal-y': '50vh', + '--reveal-radius': '0px', + '--reveal-color': THEME_CANVAS_COLORS.light, +}) let sidebarJellyTimer: ReturnType | null = null let glowFrameId: number | null = null +let lightingTargetRefreshFrameId: number | null = null +let themeApplyTimer: ReturnType | null = null +let themeRevealCleanupTimer: ReturnType | null = null +let systemThemeMediaQuery: MediaQueryList | null = null +let systemThemeChangeHandler: ((event: MediaQueryListEvent) => void) | null = null let latestPointer: { x: number; y: number } | null = null let latestPointerTarget: 'workspace' | 'login' | null = null +let workspaceLightTargets: LightingTarget[] = [] +let loginLightTargets: LightingTarget[] = [] +let workspaceLightObserver: MutationObserver | null = null +let loginLightObserver: MutationObserver | null = null const lightTargetSelector = - '.nav-item, .ghost-btn, .primary-btn, .icon-btn, .game-card, .file-main, .folder-item, .topbar, .sidebar, .panel, .hero-card, .metric-card, .explorer-toolbar, .folder-list, .file-list, .file-card, .game-player, .study-card, .path-segment, .status' + '.nav-item, .ghost-btn, .primary-btn, .icon-btn, .game-card, .file-main, .folder-item, .path-segment' const loginLightTargetSelector = '.login-card, .login-form button, .login-input-shell, .login-card h1' const navItems: SectionNavItem[] = [ @@ -113,6 +154,74 @@ const gameOptions: GameOption[] = [ ] const currentUser = computed(() => username.value.trim() || 'Guest') +const isDarkTheme = computed(() => theme.value === 'dark') +const themeToggleAriaLabel = computed(() => (isDarkTheme.value ? '切换为浅色主题' : '切换为深色主题')) +const sectionMeta = computed(() => { + switch (activeSection.value) { + case 'explorer': + return { + eyebrow: 'Explorer', + title: '文件编排', + description: '用更清晰的目录树和文件卡片处理工作区结构。', + badge: `${explorerCurrentFolder.value?.name ?? 'Workspace'} · ${explorerChildItems.value.length} 项`, + } + case 'games': + return { + eyebrow: 'Arcade', + title: '游戏中心', + description: '切换到内置小游戏,支持沉浸式全屏与快速返回。', + badge: activeGame.value ? `Now Playing · ${activeGame.value.label}` : `${gameOptions.length} 个可玩项目`, + } + case 'school': + return { + eyebrow: 'Learning', + title: '学习路径', + description: '把阶段任务、知识结构与部署能力集中到同一视图。', + badge: '3 条核心进阶路线', + } + default: + return { + eyebrow: 'Overview', + title: '总览驾驶舱', + description: '把常用入口、当前状态与近期重点放在同一块主画布里。', + badge: `${navItems.length} 个模块 · ${explorerFolders.value.length} 个目录`, + } + } +}) +const overviewSignals = computed(() => [ + { + label: '当前模块', + value: sectionMeta.value.title, + note: '主工作流状态', + }, + { + label: '路径深度', + value: `${explorerPath.value.length} 层`, + note: '当前文件位置', + }, + { + label: '主题模式', + value: isDarkTheme.value ? 'Dark' : 'Light', + note: followsSystemTheme.value ? '跟随系统' : '手动切换', + }, +]) +const studyTracks = [ + { + title: 'Frontend Systems', + description: 'Vue + TypeScript 组件拆分、状态设计、可访问性。', + meta: '组件化 · 可访问性 · 工程化', + }, + { + title: 'Graphics', + description: '游戏渲染循环、碰撞检测、输入系统与性能优化。', + meta: 'Canvas · 循环调度 · 性能', + }, + { + title: 'Deployment', + description: '构建产物、缓存策略、静态资源托管与监控。', + meta: '构建 · 托管 · 监控', + }, +] const explorerCurrentFolder = computed( () => explorerItems.value.find((item) => item.id === explorerCurrentFolderId.value) ?? explorerItems.value[0], @@ -196,38 +305,181 @@ function setSection(nextSection: SectionId) { statusMessage.value = `已切换到${navItems.find((item) => item.id === nextSection)?.title ?? ''}视图。` } -function applyMouseLighting(clientX: number, clientY: number) { - const workspace = workspaceRef.value - if (!workspace) return - - const targets = workspace.querySelectorAll(lightTargetSelector) - for (const target of targets) { - const rect = target.getBoundingClientRect() - target.style.setProperty('--lx', `${clientX - rect.left}px`) - target.style.setProperty('--ly', `${clientY - rect.top}px`) +function readStoredTheme(): Theme | null { + try { + const raw = localStorage.getItem(THEME_STORAGE_KEY) + return raw === 'light' || raw === 'dark' ? raw : null + } catch { + return null } } +function writeStoredTheme(nextTheme: Theme) { + try { + localStorage.setItem(THEME_STORAGE_KEY, nextTheme) + } catch { + // Ignore storage write errors (private mode, quota, etc.) + } +} + +function updateThemeMeta(nextTheme: Theme) { + let meta = document.querySelector('meta[name="theme-color"]') + if (!meta) { + meta = document.createElement('meta') + meta.name = 'theme-color' + document.head.appendChild(meta) + } + meta.content = THEME_CANVAS_COLORS[nextTheme] +} + +function applyTheme(nextTheme: Theme) { + theme.value = nextTheme + document.documentElement.setAttribute('data-theme', nextTheme) + updateThemeMeta(nextTheme) + scheduleLightingTargetsRefresh() +} + +function clearThemeTimers() { + if (themeApplyTimer) { + clearTimeout(themeApplyTimer) + themeApplyTimer = null + } + if (themeRevealCleanupTimer) { + clearTimeout(themeRevealCleanupTimer) + themeRevealCleanupTimer = null + } +} + +function handleThemeToggle(event: MouseEvent) { + const nextTheme = toggleTheme(theme.value) + followsSystemTheme.value = false + writeStoredTheme(nextTheme) + + const toggleButton = event.currentTarget instanceof HTMLElement ? event.currentTarget : null + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + if (reduceMotion || !toggleButton) { + themeRevealActive.value = false + clearThemeTimers() + applyTheme(nextTheme) + return + } + + clearThemeTimers() + + const rect = toggleButton.getBoundingClientRect() + const originX = rect.left + rect.width / 2 + const originY = rect.top + rect.height / 2 + const radius = calculateRevealRadius(originX, originY, window.innerWidth, window.innerHeight) + + themeRevealStyle.value = { + '--reveal-x': `${originX}px`, + '--reveal-y': `${originY}px`, + '--reveal-radius': `${radius}px`, + '--reveal-color': THEME_CANVAS_COLORS[nextTheme], + } + + themeRevealActive.value = false + requestAnimationFrame(() => { + themeRevealActive.value = true + }) + + themeApplyTimer = setTimeout(() => { + applyTheme(nextTheme) + themeApplyTimer = null + }, THEME_APPLY_OFFSET_MS) + + themeRevealCleanupTimer = setTimeout(() => { + themeRevealActive.value = false + themeRevealCleanupTimer = null + }, THEME_REVEAL_DURATION_MS) +} + +function initializeTheme() { + const storedTheme = readStoredTheme() + followsSystemTheme.value = storedTheme === null + const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches + applyTheme(resolveInitialTheme(storedTheme, prefersDark)) +} + +function buildLightingTargets(root: HTMLElement, selector: string) { + const viewportWidth = window.innerWidth + const viewportHeight = window.innerHeight + const targets: LightingTarget[] = [] + for (const element of root.querySelectorAll(selector)) { + const rect = element.getBoundingClientRect() + if (!isRectVisible(rect, viewportWidth, viewportHeight)) continue + const target: LightingTarget = { + element, + left: rect.left, + top: rect.top, + } + element.style.setProperty('--target-left', `${target.left}px`) + element.style.setProperty('--target-top', `${target.top}px`) + targets.push(target) + } + return targets +} + +function refreshLightingTargets() { + lightingTargetRefreshFrameId = null + workspaceLightTargets = workspaceRef.value ? buildLightingTargets(workspaceRef.value, lightTargetSelector) : [] + loginLightTargets = loginRef.value ? buildLightingTargets(loginRef.value, loginLightTargetSelector) : [] +} + +function scheduleLightingTargetsRefresh() { + if (lightingTargetRefreshFrameId !== null) return + lightingTargetRefreshFrameId = requestAnimationFrame(refreshLightingTargets) +} + +function setContainerPointer(container: HTMLElement | null, clientX: number, clientY: number) { + if (!container) return + container.style.setProperty('--pointer-x', `${clientX}px`) + container.style.setProperty('--pointer-y', `${clientY}px`) +} + +function clearContainerPointer(container: HTMLElement | null) { + if (!container) return + container.style.setProperty('--pointer-x', '-9999px') + container.style.setProperty('--pointer-y', '-9999px') +} + +function reconnectLightingObservers() { + if (workspaceLightObserver) { + workspaceLightObserver.disconnect() + workspaceLightObserver = null + } + if (loginLightObserver) { + loginLightObserver.disconnect() + loginLightObserver = null + } + + if (workspaceRef.value) { + workspaceLightObserver = new MutationObserver(scheduleLightingTargetsRefresh) + workspaceLightObserver.observe(workspaceRef.value, { childList: true, subtree: true }) + } + if (loginRef.value) { + loginLightObserver = new MutationObserver(scheduleLightingTargetsRefresh) + loginLightObserver.observe(loginRef.value, { childList: true, subtree: true }) + } +} + +function applyMouseLighting(clientX: number, clientY: number) { + setContainerPointer(workspaceRef.value, clientX, clientY) +} + function applyLoginLighting(clientX: number, clientY: number) { const login = loginRef.value - if (!login) return - login.classList.add('lighting-active') - - const targets = login.querySelectorAll(loginLightTargetSelector) - for (const target of targets) { - const rect = target.getBoundingClientRect() - const x = `${clientX - rect.left}px` - const y = `${clientY - rect.top}px` - target.style.setProperty('--lx', x) - target.style.setProperty('--ly', y) - target.style.setProperty('--mx', x) - target.style.setProperty('--my', y) + if (login) { + login.classList.add('lighting-active') } + setContainerPointer(login, clientX, clientY) } function flushMouseLighting() { glowFrameId = null + if (document.hidden) return if (!latestPointer) return + if (latestPointerTarget === 'workspace') { applyMouseLighting(latestPointer.x, latestPointer.y) } else if (latestPointerTarget === 'login') { @@ -236,6 +488,9 @@ function flushMouseLighting() { } function onWorkspacePointerMove(event: PointerEvent) { + if (!workspaceLightTargets.length) { + scheduleLightingTargetsRefresh() + } latestPointerTarget = 'workspace' latestPointer = { x: event.clientX, y: event.clientY } if (glowFrameId !== null) return @@ -243,17 +498,14 @@ function onWorkspacePointerMove(event: PointerEvent) { } function onWorkspacePointerLeave() { - const workspace = workspaceRef.value - if (!workspace) return - const targets = workspace.querySelectorAll(lightTargetSelector) - for (const target of targets) { - target.style.setProperty('--lx', '-9999px') - target.style.setProperty('--ly', '-9999px') - } + clearContainerPointer(workspaceRef.value) latestPointerTarget = null } function onLoginPointerMove(event: PointerEvent) { + if (!loginLightTargets.length) { + scheduleLightingTargetsRefresh() + } latestPointerTarget = 'login' latestPointer = { x: event.clientX, y: event.clientY } if (glowFrameId !== null) return @@ -264,13 +516,7 @@ function onLoginPointerLeave() { const login = loginRef.value if (!login) return login.classList.remove('lighting-active') - const targets = login.querySelectorAll(loginLightTargetSelector) - for (const target of targets) { - target.style.setProperty('--lx', '-9999px') - target.style.setProperty('--ly', '-9999px') - target.style.setProperty('--mx', '-9999px') - target.style.setProperty('--my', '-9999px') - } + clearContainerPointer(login) latestPointerTarget = null } @@ -506,6 +752,15 @@ function onFullscreenChange() { function onWindowResize() { updateSidebarIndicator(false) + scheduleLightingTargetsRefresh() +} + +function onAnyScroll() { + scheduleLightingTargetsRefresh() +} + +if (typeof window !== 'undefined') { + initializeTheme() } watch( @@ -513,98 +768,165 @@ watch( async () => { await nextTick() updateSidebarIndicator(true) + scheduleLightingTargetsRefresh() }, { flush: 'post' }, ) watch(isLoggedIn, async (loggedIn) => { - if (!loggedIn) return await nextTick() - updateSidebarIndicator(false) + if (loggedIn) { + updateSidebarIndicator(false) + } + refreshLightingTargets() + reconnectLightingObservers() + clearContainerPointer(workspaceRef.value) + clearContainerPointer(loginRef.value) }) onMounted(() => { document.addEventListener('fullscreenchange', onFullscreenChange) window.addEventListener('resize', onWindowResize) + document.addEventListener('scroll', onAnyScroll, true) + systemThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + systemThemeChangeHandler = (event) => { + if (!followsSystemTheme.value) return + applyTheme(event.matches ? 'dark' : 'light') + } + systemThemeMediaQuery.addEventListener('change', systemThemeChangeHandler) nextTick(() => { updateSidebarIndicator(false) + refreshLightingTargets() + reconnectLightingObservers() + clearContainerPointer(workspaceRef.value) + clearContainerPointer(loginRef.value) }) }) onUnmounted(() => { document.removeEventListener('fullscreenchange', onFullscreenChange) window.removeEventListener('resize', onWindowResize) + document.removeEventListener('scroll', onAnyScroll, true) + if (systemThemeMediaQuery && systemThemeChangeHandler) { + systemThemeMediaQuery.removeEventListener('change', systemThemeChangeHandler) + } + clearThemeTimers() if (sidebarJellyTimer) { clearTimeout(sidebarJellyTimer) } if (glowFrameId !== null) { cancelAnimationFrame(glowFrameId) } + if (lightingTargetRefreshFrameId !== null) { + cancelAnimationFrame(lightingTargetRefreshFrameId) + } + if (workspaceLightObserver) { + workspaceLightObserver.disconnect() + } + if (loginLightObserver) { + loginLightObserver.disconnect() + } }) diff --git a/vue/src/lighting.spec.ts b/vue/src/lighting.spec.ts new file mode 100644 index 0000000..0e9591f --- /dev/null +++ b/vue/src/lighting.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' + +import { isRectNearPointer, isRectVisible, pickNearestRectIndices, shouldRunLightingFrame } from './lighting' + +describe('isRectVisible', () => { + it('returns true when rect intersects viewport', () => { + expect(isRectVisible({ left: 10, top: 10, right: 110, bottom: 110 }, 400, 300)).toBe(true) + }) + + it('returns false when rect is far outside viewport', () => { + expect(isRectVisible({ left: 900, top: 900, right: 980, bottom: 980 }, 400, 300)).toBe(false) + }) +}) + +describe('isRectNearPointer', () => { + it('returns true when pointer is inside rect', () => { + expect(isRectNearPointer({ left: 100, top: 100, right: 200, bottom: 200 }, 150, 150, 160)).toBe(true) + }) + + it('returns true when pointer is close to rect edge', () => { + expect(isRectNearPointer({ left: 100, top: 100, right: 200, bottom: 200 }, 250, 150, 60)).toBe(true) + }) + + it('returns false when pointer is far from rect', () => { + expect(isRectNearPointer({ left: 100, top: 100, right: 200, bottom: 200 }, 500, 500, 100)).toBe(false) + }) +}) + +describe('shouldRunLightingFrame', () => { + it('throttles frames above max fps', () => { + expect(shouldRunLightingFrame(20, 0, 30)).toBe(false) + expect(shouldRunLightingFrame(40, 0, 30)).toBe(true) + }) +}) + +describe('pickNearestRectIndices', () => { + it('returns nearest indices and honors max count', () => { + const rects = [ + { left: 0, top: 0, right: 60, bottom: 60 }, + { left: 100, top: 0, right: 160, bottom: 60 }, + { left: 200, top: 0, right: 260, bottom: 60 }, + { left: 300, top: 0, right: 360, bottom: 60 }, + ] + const indices = pickNearestRectIndices(rects, 120, 20, 240, 2) + expect(indices).toEqual([1, 0]) + }) +}) diff --git a/vue/src/lighting.ts b/vue/src/lighting.ts new file mode 100644 index 0000000..ea0cf38 --- /dev/null +++ b/vue/src/lighting.ts @@ -0,0 +1,67 @@ +export interface RectLike { + left: number + top: number + right: number + bottom: number +} + +export function isRectVisible( + rect: RectLike, + viewportWidth: number, + viewportHeight: number, + overscan = 96, +) { + return !( + rect.right < -overscan || + rect.bottom < -overscan || + rect.left > viewportWidth + overscan || + rect.top > viewportHeight + overscan + ) +} + +export function isRectNearPointer(rect: RectLike, x: number, y: number, influence = 180) { + return !( + x < rect.left - influence || + x > rect.right + influence || + y < rect.top - influence || + y > rect.bottom + influence + ) +} + +export function shouldRunLightingFrame(now: number, last: number, maxFps: number) { + if (maxFps <= 0) return true + const minDelta = 1000 / maxFps + return now - last >= minDelta +} + +function rectDistanceSquared(rect: RectLike, x: number, y: number) { + const clampedX = Math.max(rect.left, Math.min(x, rect.right)) + const clampedY = Math.max(rect.top, Math.min(y, rect.bottom)) + const dx = x - clampedX + const dy = y - clampedY + return dx * dx + dy * dy +} + +export function pickNearestRectIndices( + rects: RectLike[], + x: number, + y: number, + influence = 180, + maxCount = 10, +) { + if (!rects.length || maxCount <= 0) return [] + const influenceSquared = influence * influence + const matches: Array<{ i: number; d: number }> = [] + + for (let i = 0; i < rects.length; i += 1) { + const rect = rects[i] + if (!rect) continue + const d = rectDistanceSquared(rect, x, y) + if (d <= influenceSquared) { + matches.push({ i, d }) + } + } + + matches.sort((a, b) => a.d - b.d) + return matches.slice(0, maxCount).map((item) => item.i) +} diff --git a/vue/src/style.css b/vue/src/style.css index da4be43..c03b7b6 100644 --- a/vue/src/style.css +++ b/vue/src/style.css @@ -1,32 +1,12 @@ @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@500;600&display=swap'); :root { - --bg-canvas: #f4f7fb; - --bg-radial: #e8eef8; - --panel: #ffffff; - --panel-soft: #f9fbfe; - --panel-elevated: #f2f6fb; - - --ink-1: #16263a; - --ink-2: #3f546d; - --ink-3: #6d8299; - - --line: #dbe4ef; - --line-strong: #bfd0e2; - - --brand-1: #2f6fed; - --brand-2: #178e8f; - --brand-warm: #f0a24a; - - --focus: #f3b558; - --danger: #dd3f57; - --radius-s: 10px; --radius-m: 14px; --radius-l: 20px; - --shadow-soft: 0 10px 26px rgba(22, 39, 58, 0.09); - --shadow-strong: 0 20px 46px rgba(22, 39, 58, 0.15); + --shadow-soft: 0 10px 26px rgba(17, 31, 48, 0.1); + --shadow-strong: 0 20px 46px rgba(17, 31, 48, 0.18); --ease-standard: cubic-bezier(0.22, 0.61, 0.36, 1); font-family: 'Manrope', 'Noto Sans SC', 'Segoe UI', sans-serif; @@ -37,6 +17,64 @@ -moz-osx-font-smoothing: grayscale; } +html:not([data-theme]), +html[data-theme='light'] { + --bg-canvas: #f7f9fc; + --bg-radial: #edf3fb; + --panel: #ffffff; + --panel-soft: #f9fbff; + --panel-elevated: #f3f7fc; + + --ink-1: #1f2a3d; + --ink-2: #415570; + --ink-3: #6f849f; + + --line: #d9e4ef; + --line-strong: #bfd0e2; + + --brand-1: #0090ff; + --brand-2: #00a2c7; + --brand-warm: #ffb224; + + --focus: #ffb224; + --danger: #e5484d; + --tone-blue-soft: #edf5ff; + --tone-cyan-soft: #e9f9fb; + --tone-warm-soft: #fff6e8; + --tone-indigo-soft: #f1f1ff; + color-scheme: light; +} + +html[data-theme='dark'] { + --bg-canvas: #070d16; + --bg-radial: #0b1524; + --panel: #101a29; + --panel-soft: #122032; + --panel-elevated: #16253a; + + --ink-1: #e6f0ff; + --ink-2: #b9cbe6; + --ink-3: #8fa5c2; + + --line: #2a3f5b; + --line-strong: #355174; + + --brand-1: #0090ff; + --brand-2: #00a2c7; + --brand-warm: #ffb224; + + --focus: #ffb224; + --danger: #ff6369; + --tone-blue-soft: #14243a; + --tone-cyan-soft: #132a33; + --tone-warm-soft: #302614; + --tone-indigo-soft: #1b1f38; + + --shadow-soft: 0 14px 30px rgba(0, 0, 0, 0.36); + --shadow-strong: 0 24px 52px rgba(0, 0, 0, 0.48); + color-scheme: dark; +} + * { box-sizing: border-box; } @@ -53,9 +91,11 @@ body { min-width: 320px; color: var(--ink-1); background: - radial-gradient(circle at 14% 6%, rgba(47, 111, 237, 0.12), transparent 42%), - radial-gradient(circle at 92% 12%, rgba(240, 162, 74, 0.1), transparent 36%), + radial-gradient(circle at 14% 6%, color-mix(in srgb, var(--brand-1) 18%, transparent), transparent 44%), + radial-gradient(circle at 84% 10%, color-mix(in srgb, var(--brand-2) 16%, transparent), transparent 38%), + radial-gradient(circle at 92% 16%, color-mix(in srgb, var(--brand-warm) 12%, transparent), transparent 30%), linear-gradient(155deg, var(--bg-radial), var(--bg-canvas)); + transition: background-color 220ms var(--ease-standard), color 220ms var(--ease-standard); } a { @@ -86,6 +126,140 @@ input { outline: 3px solid var(--focus); } +.theme-reveal-layer { + position: fixed; + inset: 0; + z-index: 160; + pointer-events: none; + opacity: 0; + background: var(--reveal-color, var(--bg-canvas)); + clip-path: circle(0px at var(--reveal-x, 50vw) var(--reveal-y, 50vh)); +} + +.theme-reveal-layer.active { + opacity: 1; + animation: themeRevealExpand 520ms var(--ease-standard) forwards; +} + +.theme-toggle { + position: relative; + min-width: 88px; + min-height: 42px; + border-radius: 999px; + border: 1px solid var(--line-strong); + padding: 4px; + background: linear-gradient( + 145deg, + color-mix(in srgb, var(--panel) 80%, var(--brand-1) 20%), + color-mix(in srgb, var(--panel) 90%, var(--brand-2) 10%) + ); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35); + cursor: pointer; + transition: + border-color 180ms var(--ease-standard), + box-shadow 180ms var(--ease-standard), + transform 180ms var(--ease-standard); +} + +.theme-toggle:hover { + border-color: color-mix(in srgb, var(--brand-1) 45%, var(--line-strong) 55%); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.42), + 0 8px 18px rgba(0, 0, 0, 0.12); +} + +.theme-toggle-track { + position: relative; + height: 100%; + min-height: 32px; + border-radius: inherit; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.76rem; + letter-spacing: 0.02em; +} + +.theme-toggle-sun, +.theme-toggle-moon { + position: relative; + z-index: 2; + opacity: 0.7; + transition: opacity 180ms var(--ease-standard), color 180ms var(--ease-standard); +} + +.theme-toggle-sun { + color: color-mix(in srgb, var(--brand-warm) 72%, #6d4a1a 28%); +} + +.theme-toggle-moon { + color: color-mix(in srgb, var(--brand-1) 75%, #2f4a7a 25%); +} + +.theme-toggle-thumb { + position: absolute; + top: 50%; + left: 4px; + width: 34px; + height: 34px; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--line-strong) 80%, var(--brand-1) 20%); + background: linear-gradient( + 160deg, + color-mix(in srgb, #ffffff 86%, var(--brand-warm) 14%), + color-mix(in srgb, #ffffff 78%, var(--brand-1) 22%) + ); + box-shadow: + 0 8px 16px rgba(10, 20, 34, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.75); + transform: translate3d(0, -50%, 0); + transition: transform 250ms var(--ease-standard), background 220ms var(--ease-standard); +} + +.theme-toggle.is-dark .theme-toggle-thumb { + transform: translate3d(42px, -50%, 0); + background: linear-gradient( + 160deg, + color-mix(in srgb, #cfe3ff 74%, var(--brand-1) 26%), + color-mix(in srgb, #9fbeea 68%, var(--brand-2) 32%) + ); +} + +.theme-toggle.is-dark .theme-toggle-sun, +.theme-toggle:not(.is-dark) .theme-toggle-moon { + opacity: 0.46; +} + +.theme-toggle-floating { + position: absolute; + top: 18px; + right: 18px; + z-index: 3; +} + +.view-swap-enter-active, +.view-swap-leave-active { + transition: + opacity 360ms var(--ease-standard), + transform 420ms var(--ease-standard), + filter 360ms var(--ease-standard); +} + +.view-swap-enter-from { + opacity: 0; + transform: translateY(18px) scale(0.985); + filter: blur(6px); +} + +.view-swap-leave-to { + opacity: 0; + transform: translateY(-12px) scale(0.992); + filter: blur(4px); +} + .login-view, .workspace-view { min-height: 100vh; @@ -95,13 +269,40 @@ input { .login-view { position: relative; isolation: isolate; - --mx: -999px; - --my: -999px; + --pointer-x: -9999px; + --pointer-y: -9999px; display: grid; place-items: center; overflow: hidden; } +.workspace-view { + --pointer-x: -9999px; + --pointer-y: -9999px; +} + +.workspace-view .nav-item, +.workspace-view .ghost-btn, +.workspace-view .primary-btn, +.workspace-view .icon-btn, +.workspace-view .game-card, +.workspace-view .file-main, +.workspace-view .folder-item, +.workspace-view .path-segment { + --lx: calc(var(--pointer-x, -9999px) - var(--target-left, 0px)); + --ly: calc(var(--pointer-y, -9999px) - var(--target-top, 0px)); +} + +.login-view .login-card, +.login-view .login-form button, +.login-view .login-input-shell, +.login-view .login-card h1 { + --lx: calc(var(--pointer-x, -9999px) - var(--target-left, 0px)); + --ly: calc(var(--pointer-y, -9999px) - var(--target-top, 0px)); + --mx: var(--lx); + --my: var(--ly); +} + .login-view::before { display: none; } @@ -412,6 +613,15 @@ input { overflow: hidden; } +@keyframes themeRevealExpand { + 0% { + clip-path: circle(0px at var(--reveal-x, 50vw) var(--reveal-y, 50vh)); + } + 100% { + clip-path: circle(var(--reveal-radius, 150vmax) at var(--reveal-x, 50vw) var(--reveal-y, 50vh)); + } +} + .topbar { position: relative; z-index: 1; @@ -595,6 +805,9 @@ input { height: 100%; padding: 16px; overflow: auto; + background: + linear-gradient(180deg, color-mix(in srgb, var(--panel) 92%, var(--bg-radial) 8%), var(--panel)), + radial-gradient(circle at 92% 12%, color-mix(in srgb, var(--brand-1) 9%, transparent), transparent 52%); } .overview-panel { @@ -613,6 +826,13 @@ input { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); } +.overview-panel .hero-card { + background: + linear-gradient(145deg, #fff, color-mix(in srgb, var(--tone-blue-soft) 74%, #ffffff 26%)), + radial-gradient(circle at 92% 10%, color-mix(in srgb, var(--brand-2) 24%, transparent), transparent 34%), + radial-gradient(circle at 78% 18%, color-mix(in srgb, var(--brand-warm) 22%, transparent), transparent 30%); +} + .hero-card h2 { margin: 0; font-size: clamp(1.1rem, 2.2vw, 1.5rem); @@ -648,6 +868,10 @@ input { padding: 14px; } +.metric-grid .metric-card { + background: linear-gradient(145deg, #ffffff, var(--tone-blue-soft)); +} + .metric-card p { margin: 0; color: var(--ink-3); @@ -676,7 +900,7 @@ input { grid-template-columns: auto 1fr auto; gap: 8px; align-items: center; - background: #f9fcff; + background: linear-gradient(145deg, #f9fcff, var(--tone-blue-soft)); } .pathbar { @@ -730,12 +954,16 @@ input { .file-list { border: 1px solid var(--line); border-radius: var(--radius-m); - background: var(--panel-soft); + background: linear-gradient(145deg, var(--panel-soft), var(--tone-blue-soft)); min-height: 0; content-visibility: auto; contain-intrinsic-size: 400px; } +.file-list { + background: linear-gradient(145deg, var(--panel-soft), var(--tone-cyan-soft)); +} + .folder-list { padding: 8px; display: grid; @@ -820,7 +1048,7 @@ input { .file-card { border: 1px solid var(--line); border-radius: var(--radius-s); - background: #fff; + background: linear-gradient(145deg, #ffffff, color-mix(in srgb, var(--tone-indigo-soft) 35%, #ffffff 65%)); padding: 8px; display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -933,7 +1161,7 @@ input { border: 1px solid var(--line); border-radius: var(--radius-m); padding: 14px; - background: linear-gradient(145deg, #fff, #f7fbfe); + background: linear-gradient(145deg, #fff, var(--tone-blue-soft)); text-align: left; display: grid; align-content: start; @@ -1021,10 +1249,14 @@ input { .study-card { border: 1px solid var(--line); border-radius: var(--radius-m); - background: #fff; + background: linear-gradient(145deg, #ffffff, var(--tone-blue-soft)); padding: 14px; } +.study-grid .study-card { + background: linear-gradient(145deg, #ffffff, var(--tone-blue-soft)); +} + .study-card h3 { margin: 0; font-size: 1rem; @@ -1217,68 +1449,52 @@ input { } /* Dark Theme Overrides */ -:root { - --bg-canvas: #070b12; - --bg-radial: #0d1522; - --panel: #0f1724; - --panel-soft: #111b2b; - --panel-elevated: #142033; - - --ink-1: #e8f0ff; - --ink-2: #b9c9e1; - --ink-3: #8fa5c2; - - --line: #243449; - --line-strong: #31465f; - - --brand-1: #4f8bff; - --brand-2: #2eb4bd; - --brand-warm: #f4b25d; - - --focus: #ffc86d; - --danger: #ff6d8d; - - --shadow-soft: 0 14px 30px rgba(0, 0, 0, 0.35); - --shadow-strong: 0 24px 52px rgba(0, 0, 0, 0.46); -} - -html { - color-scheme: dark; -} - -body { +html[data-theme='dark'] body { color: var(--ink-1); background: - radial-gradient(circle at 14% 6%, rgba(79, 139, 255, 0.22), transparent 44%), - radial-gradient(circle at 88% 10%, rgba(244, 178, 93, 0.16), transparent 36%), - linear-gradient(155deg, #0c1422, #070b12); + radial-gradient(circle at 14% 6%, rgba(0, 144, 255, 0.24), transparent 46%), + radial-gradient(circle at 84% 10%, rgba(0, 162, 199, 0.2), transparent 38%), + radial-gradient(circle at 92% 16%, rgba(255, 178, 36, 0.16), transparent 32%), + linear-gradient(155deg, var(--bg-radial), var(--bg-canvas)); } -.login-card, -.topbar, -.sidebar, -.panel, -.hero-card, -.metric-card, -.explorer-toolbar, -.folder-list, -.file-list, -.file-card, -.game-player, -.study-card, -.path-segment, -.status { - background: linear-gradient(145deg, #121d2d, #0d1624); - border-color: #2b3d55; +html[data-theme='dark'] .theme-toggle { + border-color: #355174; + background: linear-gradient(145deg, #14253a, #112034); +} + +html[data-theme='dark'] .theme-toggle-thumb { + border-color: #3c5d84; + box-shadow: + 0 8px 16px rgba(0, 0, 0, 0.28), + inset 0 1px 0 rgba(255, 255, 255, 0.25); +} + +html[data-theme='dark'] .login-card, +html[data-theme='dark'] .topbar, +html[data-theme='dark'] .sidebar, +html[data-theme='dark'] .panel, +html[data-theme='dark'] .hero-card, +html[data-theme='dark'] .metric-card, +html[data-theme='dark'] .explorer-toolbar, +html[data-theme='dark'] .folder-list, +html[data-theme='dark'] .file-list, +html[data-theme='dark'] .file-card, +html[data-theme='dark'] .game-player, +html[data-theme='dark'] .study-card, +html[data-theme='dark'] .path-segment, +html[data-theme='dark'] .status { + background: linear-gradient(145deg, #121f31, #0f1929); + border-color: #2f4664; box-shadow: var(--shadow-soft); } -.login-card { - border-color: #3a506d; +html[data-theme='dark'] .login-card { + border-color: #3a587c; box-shadow: var(--shadow-strong); } -.login-card::after { +html[data-theme='dark'] .login-card::after { background: radial-gradient( 220px circle at var(--lx, -9999px) var(--ly, -9999px), rgba(197, 223, 255, 0.75) 0%, @@ -1288,22 +1504,22 @@ body { ); } -.eyebrow { +html[data-theme='dark'] .eyebrow { color: #9eb2cb; } -.subtitle, -.hero-card p, -.study-card p { +html[data-theme='dark'] .subtitle, +html[data-theme='dark'] .hero-card p, +html[data-theme='dark'] .study-card p { color: var(--ink-2); } -.login-card h1 { +html[data-theme='dark'] .login-card h1 { color: #edf4ff; } @supports ((-webkit-background-clip: text) or (background-clip: text)) { - .login-card h1 { + html[data-theme='dark'] .login-card h1 { background-image: radial-gradient( 170px circle at var(--mx, -9999px) var(--my, -9999px), #ffffff 0%, @@ -1314,11 +1530,11 @@ body { } } -.login-form label { +html[data-theme='dark'] .login-form label { color: #d5e3f8; } -.login-input-shell { +html[data-theme='dark'] .login-input-shell { background: radial-gradient( 120px circle at var(--mx, -9999px) var(--my, -9999px), @@ -1340,135 +1556,135 @@ body { 0 0 0 1px rgba(55, 82, 117, 0.7); } -.login-form input { +html[data-theme='dark'] .login-form input { color: #ecf4ff; } -.login-form input::placeholder { +html[data-theme='dark'] .login-form input::placeholder { color: #8098b7; } -.login-form button, -.primary-btn { - background: linear-gradient(135deg, #4f8bff, #2d6edf); - border-color: rgba(108, 155, 230, 0.62); - color: #eef5ff; - box-shadow: 0 8px 18px rgba(54, 99, 177, 0.36); +html[data-theme='dark'] .login-form button, +html[data-theme='dark'] .primary-btn { + background: linear-gradient(135deg, #3f9cff, #0089d8); + border-color: rgba(120, 176, 240, 0.65); + color: #eef6ff; + box-shadow: 0 8px 18px rgba(24, 92, 177, 0.4); } -.ghost-btn, -.icon-btn, -.path-segment, -.folder-item, -.file-main, -.game-card { - background: linear-gradient(145deg, #132033, #0f1a2a); - border-color: #324861; +html[data-theme='dark'] .ghost-btn, +html[data-theme='dark'] .icon-btn, +html[data-theme='dark'] .path-segment, +html[data-theme='dark'] .folder-item, +html[data-theme='dark'] .file-main, +html[data-theme='dark'] .game-card { + background: linear-gradient(145deg, #132238, #0f1b2c); + border-color: #355174; color: #dceaff; } -.ghost-btn:hover, -.icon-btn:hover, -.path-segment:hover, -.folder-item:hover, -.file-main:hover, -.game-card:hover { - background: linear-gradient(145deg, #16263d, #122035); - border-color: #456181; +html[data-theme='dark'] .ghost-btn:hover, +html[data-theme='dark'] .icon-btn:hover, +html[data-theme='dark'] .path-segment:hover, +html[data-theme='dark'] .folder-item:hover, +html[data-theme='dark'] .file-main:hover, +html[data-theme='dark'] .game-card:hover { + background: linear-gradient(145deg, #172a43, #14243b); + border-color: #4a6f9a; } -.nav-item { - background: linear-gradient(145deg, #121f32, #0f1a2a); - border-color: #30455f; +html[data-theme='dark'] .nav-item { + background: linear-gradient(145deg, #122237, #0f1b2c); + border-color: #355174; color: #dce8fb; } -.nav-item:hover { - background: linear-gradient(145deg, #16263d, #132238); - border-color: #49688a; +html[data-theme='dark'] .nav-item:hover { + background: linear-gradient(145deg, #172a43, #13263d); + border-color: #4f77a5; } -.nav-item.active { - background: linear-gradient(135deg, rgba(85, 135, 218, 0.22), rgba(68, 117, 196, 0.14)); - border-color: #5f84b6; +html[data-theme='dark'] .nav-item.active { + background: linear-gradient(135deg, rgba(0, 144, 255, 0.24), rgba(0, 162, 199, 0.16)); + border-color: #6395cc; } -.nav-icon, -.file-icon, -.game-icon { - background: linear-gradient(145deg, #1a2a42, #142236); - border-color: #3e5775; - color: #85b8f7; +html[data-theme='dark'] .nav-icon, +html[data-theme='dark'] .file-icon, +html[data-theme='dark'] .game-icon { + background: linear-gradient(145deg, #19304b, #14263c); + border-color: #45688f; + color: #88c8f9; } -.nav-icon-glyph { - color: #7eb4f3; +html[data-theme='dark'] .nav-icon-glyph { + color: #84bdf3; } -.nav-copy small, -.metric-card p, -.file-text small, -.game-card small, -.empty { +html[data-theme='dark'] .nav-copy small, +html[data-theme='dark'] .metric-card p, +html[data-theme='dark'] .file-text small, +html[data-theme='dark'] .game-card small, +html[data-theme='dark'] .empty { color: var(--ink-3); } -.user-chip { - background: linear-gradient(145deg, #17273d, #112033); - border-color: #3b526d; +html[data-theme='dark'] .user-chip { + background: linear-gradient(145deg, #18304a, #13243a); + border-color: #45688f; color: #d8e8fd; } -.folder-item.active, -.file-main.selected { - background: linear-gradient(135deg, rgba(77, 131, 219, 0.24), rgba(66, 109, 181, 0.2)); - border-color: #678dbc; +html[data-theme='dark'] .folder-item.active, +html[data-theme='dark'] .file-main.selected { + background: linear-gradient(135deg, rgba(0, 144, 255, 0.24), rgba(0, 162, 199, 0.2)); + border-color: #6b9fd3; } -.file-card, -.metric-card, -.study-card, -.hero-card { - border-color: #2d4158; +html[data-theme='dark'] .file-card, +html[data-theme='dark'] .metric-card, +html[data-theme='dark'] .study-card, +html[data-theme='dark'] .hero-card { + border-color: #34506f; } -.file-card, -.study-card, -.metric-card { - background: linear-gradient(145deg, #131f31, #101a2a); +html[data-theme='dark'] .file-card, +html[data-theme='dark'] .study-card, +html[data-theme='dark'] .metric-card { + background: linear-gradient(145deg, #132238, #101c2e); } -.explorer-toolbar, -.game-player-bar { - background: linear-gradient(145deg, #152338, #111e30); - border-color: #30465f; +html[data-theme='dark'] .explorer-toolbar, +html[data-theme='dark'] .game-player-bar { + background: linear-gradient(145deg, #15283f, #122136); + border-color: #365376; } -.game-player-bar strong, -.game-title-wrap, -.topbar h1 { +html[data-theme='dark'] .game-player-bar strong, +html[data-theme='dark'] .game-title-wrap, +html[data-theme='dark'] .topbar h1 { color: #e8f1ff; } -.status { - background: linear-gradient(145deg, #2a2216, #211a11); - border-color: #5c4a2f; - color: #ffdca3; +html[data-theme='dark'] .status { + background: linear-gradient(145deg, #312813, #271f0f); + border-color: #6d5528; + color: #ffdd9c; } -.topbar::after, -.sidebar::after, -.panel::after, -.hero-card::after, -.metric-card::after, -.explorer-toolbar::after, -.folder-list::after, -.file-list::after, -.file-card::after, -.game-player::after, -.study-card::after, -.path-segment::after, -.status::after { +html[data-theme='dark'] .topbar::after, +html[data-theme='dark'] .sidebar::after, +html[data-theme='dark'] .panel::after, +html[data-theme='dark'] .hero-card::after, +html[data-theme='dark'] .metric-card::after, +html[data-theme='dark'] .explorer-toolbar::after, +html[data-theme='dark'] .folder-list::after, +html[data-theme='dark'] .file-list::after, +html[data-theme='dark'] .file-card::after, +html[data-theme='dark'] .game-player::after, +html[data-theme='dark'] .study-card::after, +html[data-theme='dark'] .path-segment::after, +html[data-theme='dark'] .status::after { background: radial-gradient( 220px circle at var(--lx, -9999px) var(--ly, -9999px), rgba(213, 233, 255, 0.58) 0%, @@ -1478,5 +1694,522 @@ body { ); } +html[data-theme='dark'] .panel-body { + background: + linear-gradient(180deg, color-mix(in srgb, #112034 86%, #0b1524 14%), #101a29), + radial-gradient(circle at 90% 12%, rgba(0, 144, 255, 0.2), transparent 50%); +} + +html[data-theme='dark'] .overview-panel .hero-card { + background: + linear-gradient(145deg, #15283f, #13253a), + radial-gradient(circle at 92% 10%, rgba(0, 162, 199, 0.3), transparent 36%), + radial-gradient(circle at 78% 18%, rgba(255, 178, 36, 0.22), transparent 30%); +} + +html[data-theme='dark'] .metric-grid .metric-card { + background: linear-gradient(145deg, #152b43, #13253a); +} + +html[data-theme='dark'] .explorer-toolbar { + background: linear-gradient(145deg, #152a42, #13263c); +} + +html[data-theme='dark'] .folder-list { + background: linear-gradient(145deg, #15263d, #122238); +} + +html[data-theme='dark'] .file-list { + background: linear-gradient(145deg, #14313d, #122a35); +} + +html[data-theme='dark'] .file-card { + background: linear-gradient(145deg, #162942, #14243b); +} + +html[data-theme='dark'] .game-card { + background: linear-gradient(145deg, #152a43, #13263d); +} + +html[data-theme='dark'] .study-card { + background: linear-gradient(145deg, #152a43, #13253a); +} + +html[data-theme='dark'] .study-grid .study-card { + background: linear-gradient(145deg, #152a43, #13253a); +} + +:root { + --radius-xl: 30px; + --shadow-glow: 0 18px 44px rgba(36, 84, 156, 0.16); + --ease-expressive: cubic-bezier(0.2, 0.9, 0.2, 1); +} + +body { + background-attachment: fixed; +} + +.workspace-view { + position: relative; +} + +.workspace-view::before, +.workspace-view::after { + content: ''; + position: fixed; + border-radius: 999px; + pointer-events: none; + z-index: 0; + filter: blur(18px); +} + +.workspace-view::before { + top: 110px; + right: clamp(20px, 8vw, 140px); + width: min(34vw, 420px); + height: min(34vw, 420px); + background: radial-gradient(circle, color-mix(in srgb, var(--brand-1) 20%, transparent), transparent 68%); + animation: floatOrb 16s ease-in-out infinite; +} + +.workspace-view::after { + bottom: 60px; + left: clamp(-40px, 4vw, 60px); + width: min(26vw, 300px); + height: min(26vw, 300px); + background: radial-gradient(circle, color-mix(in srgb, var(--brand-warm) 18%, transparent), transparent 70%); + animation: floatOrb 18s ease-in-out infinite reverse; +} + +.topbar, +.sidebar, +.panel { + border-radius: var(--radius-xl); + backdrop-filter: blur(16px); +} + +.topbar { + background: + linear-gradient(145deg, color-mix(in srgb, var(--panel) 86%, #ffffff 14%), color-mix(in srgb, var(--panel-soft) 92%, var(--tone-blue-soft) 8%)), + radial-gradient(circle at 84% 18%, color-mix(in srgb, var(--brand-2) 12%, transparent), transparent 36%); + box-shadow: var(--shadow-glow); + padding: 18px 20px; +} + +.user-chip { + background: linear-gradient(145deg, #f9fcff, #edf5ff); +} + +.workspace-layout { + gap: 16px; +} + +.sidebar, +.panel { + background: + linear-gradient(180deg, color-mix(in srgb, var(--panel) 94%, #ffffff 6%), color-mix(in srgb, var(--panel-soft) 100%, transparent)), + var(--panel); +} + +.sidebar { + padding: 14px; + gap: 10px; +} + +.nav-item { + background: + linear-gradient(145deg, color-mix(in srgb, var(--panel-soft) 92%, #ffffff 8%), color-mix(in srgb, var(--tone-blue-soft) 70%, var(--panel-soft) 30%)); + transition: + border-color 180ms var(--ease-standard), + background-color 180ms var(--ease-standard), + transform 220ms var(--ease-expressive), + box-shadow 220ms var(--ease-expressive); +} + +.nav-item:hover { + background: linear-gradient(145deg, #fbfdff, #eef5ff); + transform: translate3d(0, -3px, 0); + box-shadow: 0 14px 24px rgba(35, 59, 89, 0.1); +} + +.panel-body { + padding: 18px; +} + +.panel-intro { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: end; + margin-bottom: 16px; +} + +.panel-intro h2 { + margin: 4px 0 0; + font-size: clamp(1.4rem, 2.6vw, 2rem); + line-height: 1.08; + text-wrap: balance; +} + +.panel-intro p { + margin: 10px 0 0; + max-width: 62ch; + color: var(--ink-2); +} + +.panel-intro-badge { + align-self: start; + border: 1px solid color-mix(in srgb, var(--line-strong) 70%, var(--brand-1) 30%); + border-radius: 999px; + padding: 8px 12px; + background: linear-gradient(145deg, color-mix(in srgb, var(--tone-blue-soft) 72%, #ffffff 28%), color-mix(in srgb, var(--tone-indigo-soft) 60%, #ffffff 40%)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78); + color: var(--ink-2); + font-family: 'IBM Plex Mono', monospace; + font-size: 0.78rem; + letter-spacing: 0.04em; +} + +.panel-section { + display: grid; + gap: 14px; + animation: sectionRise 420ms var(--ease-expressive); +} + +.overview-panel { + gap: 14px; +} + +.overview-hero-grid { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr); + gap: 14px; +} + +.hero-card-featured { + min-height: 310px; + padding: 24px; + display: grid; + align-content: space-between; + gap: 20px; + background: + radial-gradient(circle at 82% 16%, color-mix(in srgb, var(--brand-1) 18%, transparent), transparent 30%), + radial-gradient(circle at 96% 12%, color-mix(in srgb, var(--brand-warm) 24%, transparent), transparent 28%), + linear-gradient(145deg, #ffffff, color-mix(in srgb, var(--tone-blue-soft) 76%, #ffffff 24%)); +} + +.hero-copy { + display: grid; + gap: 10px; +} + +.hero-kicker, +.insight-pill, +.study-meta, +.game-meta { + display: inline-flex; + width: fit-content; + border-radius: 999px; + padding: 5px 10px; + font-family: 'IBM Plex Mono', monospace; + font-size: 0.74rem; + letter-spacing: 0.04em; +} + +.hero-kicker { + border: 1px solid color-mix(in srgb, var(--brand-1) 18%, var(--line) 82%); + background: color-mix(in srgb, var(--tone-blue-soft) 80%, #ffffff 20%); + color: #3d6694; +} + +.hero-card-featured h3, +.school-panel .hero-card h3 { + margin: 0; + font-size: clamp(1.45rem, 2.7vw, 2.3rem); + line-height: 1; + text-wrap: balance; +} + +.hero-stat-strip { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.hero-stat { + position: relative; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--line) 72%, var(--brand-1) 28%); + border-radius: 18px; + min-height: 98px; + padding: 14px; + background: linear-gradient(160deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.56)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); +} + +.hero-stat::before { + content: ''; + position: absolute; + inset: auto -28px -44px auto; + width: 100px; + height: 100px; + border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--brand-1) 18%, transparent), transparent 70%); +} + +.hero-stat small, +.hero-stat span { + display: block; +} + +.hero-stat small { + color: var(--ink-3); +} + +.hero-stat strong { + display: block; + margin-top: 8px; + font-size: 1.3rem; + letter-spacing: -0.02em; +} + +.hero-stat span { + margin-top: 6px; + color: var(--ink-2); + font-size: 0.86rem; +} + +.insight-rail { + display: grid; + gap: 12px; +} + +.insight-card { + min-height: 148px; + border: 1px solid var(--line); + border-radius: 24px; + padding: 18px; + background: + linear-gradient(160deg, color-mix(in srgb, var(--panel) 90%, #ffffff 10%), color-mix(in srgb, var(--tone-indigo-soft) 74%, #ffffff 26%)), + var(--panel); + box-shadow: var(--shadow-soft); + display: grid; + align-content: start; + gap: 10px; +} + +.insight-card.ambient { + background: + radial-gradient(circle at 88% 18%, color-mix(in srgb, var(--brand-2) 26%, transparent), transparent 28%), + linear-gradient(160deg, color-mix(in srgb, var(--tone-blue-soft) 74%, #ffffff 26%), color-mix(in srgb, var(--tone-cyan-soft) 78%, #ffffff 22%)); +} + +.insight-pill { + border: 1px solid color-mix(in srgb, var(--brand-2) 24%, var(--line) 76%); + background: rgba(255, 255, 255, 0.66); + color: #2a6583; +} + +.insight-pill.neutral { + color: var(--ink-2); +} + +.insight-card strong { + font-size: 1.2rem; +} + +.insight-card p { + margin: 0; + color: var(--ink-2); +} + +.metric-card { + border-radius: 18px; + min-height: 142px; + padding: 16px; + transition: + transform 220ms var(--ease-expressive), + box-shadow 220ms var(--ease-expressive), + border-color 180ms var(--ease-standard); +} + +.metric-card strong { + font-size: 2rem; +} + +.metric-card span { + display: block; + margin-top: 10px; + color: var(--ink-2); + font-size: 0.88rem; +} + +.explorer-toolbar { + padding: 12px; + background: + radial-gradient(circle at 90% 18%, color-mix(in srgb, var(--brand-1) 10%, transparent), transparent 26%), + linear-gradient(145deg, #f9fcff, var(--tone-blue-soft)); +} + +.file-card, +.game-card, +.study-card { + transition: + transform 220ms var(--ease-expressive), + box-shadow 220ms var(--ease-expressive), + border-color 180ms var(--ease-standard); +} + +.metric-card:hover, +.file-card:hover, +.game-card:hover, +.study-card:hover { + transform: translate3d(0, -4px, 0); + box-shadow: 0 18px 34px rgba(31, 55, 88, 0.12); +} + +.game-grid, +.study-grid { + gap: 12px; +} + +.game-card { + min-height: 160px; + border-radius: 20px; + padding: 16px; + background: + radial-gradient(circle at 88% 14%, color-mix(in srgb, var(--brand-2) 16%, transparent), transparent 26%), + linear-gradient(145deg, #fff, var(--tone-blue-soft)); +} + +.game-card:hover { + transform: translate3d(0, -4px, 0) rotate(-0.25deg); +} + +.game-meta { + margin-top: auto; + border: 1px solid color-mix(in srgb, var(--brand-1) 18%, var(--line) 82%); + background: rgba(255, 255, 255, 0.72); + color: #41678f; +} + +.study-card { + border-radius: 20px; + padding: 16px; + min-height: 188px; +} + +.study-card h3 { + margin: 14px 0 0; +} + +.study-meta { + border: 1px solid color-mix(in srgb, var(--line-strong) 72%, var(--brand-2) 28%); + background: rgba(255, 255, 255, 0.7); + color: #3c6882; +} + +html[data-theme='dark'] .topbar, +html[data-theme='dark'] .sidebar, +html[data-theme='dark'] .panel { + background: + linear-gradient(180deg, rgba(18, 31, 49, 0.94), rgba(13, 24, 38, 0.96)), + #101a29; +} + +html[data-theme='dark'] .topbar { + box-shadow: 0 22px 48px rgba(0, 0, 0, 0.34); +} + +html[data-theme='dark'] .hero-card-featured, +html[data-theme='dark'] .insight-card.ambient, +html[data-theme='dark'] .game-card { + background: + radial-gradient(circle at 88% 16%, rgba(0, 144, 255, 0.18), transparent 28%), + linear-gradient(145deg, #15283f, #13253a); +} + +html[data-theme='dark'] .panel-intro-badge, +html[data-theme='dark'] .hero-stat, +html[data-theme='dark'] .game-meta, +html[data-theme='dark'] .study-meta, +html[data-theme='dark'] .insight-pill { + background: rgba(12, 24, 39, 0.78); + border-color: #355174; + color: #cfe0f7; +} + +html[data-theme='dark'] .hero-stat span, +html[data-theme='dark'] .insight-card p, +html[data-theme='dark'] .metric-card span, +html[data-theme='dark'] .panel-intro p { + color: var(--ink-2); +} + +@keyframes floatOrb { + 0%, + 100% { + transform: translate3d(0, 0, 0) scale(1); + } + 50% { + transform: translate3d(0, -18px, 0) scale(1.05); + } +} + +@keyframes sectionRise { + from { + opacity: 0; + transform: translate3d(0, 18px, 0); + } + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } +} + +@media (max-width: 1050px) { + .panel-intro, + .overview-hero-grid, + .hero-stat-strip { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + .panel-body { + padding: 14px; + } + + .panel-intro-badge { + justify-self: start; + } + + .hero-card-featured { + min-height: auto; + padding: 18px; + } + + .hero-stat { + min-height: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } + + .theme-reveal-layer.active { + animation: none; + opacity: 1; + clip-path: circle(var(--reveal-radius, 150vmax) at var(--reveal-x, 50vw) var(--reveal-y, 50vh)); + } +} + diff --git a/vue/src/theme.spec.ts b/vue/src/theme.spec.ts new file mode 100644 index 0000000..447b858 --- /dev/null +++ b/vue/src/theme.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { calculateRevealRadius, resolveInitialTheme, toggleTheme } from './theme' + +describe('resolveInitialTheme', () => { + it('uses stored theme when present', () => { + expect(resolveInitialTheme('dark', false)).toBe('dark') + expect(resolveInitialTheme('light', true)).toBe('light') + }) + + it('falls back to system preference when no stored theme', () => { + expect(resolveInitialTheme(null, true)).toBe('dark') + expect(resolveInitialTheme(null, false)).toBe('light') + }) +}) + +describe('toggleTheme', () => { + it('toggles between light and dark', () => { + expect(toggleTheme('light')).toBe('dark') + expect(toggleTheme('dark')).toBe('light') + }) +}) + +describe('calculateRevealRadius', () => { + it('returns max distance from origin to viewport corners', () => { + const radius = calculateRevealRadius(100, 100, 1000, 600) + expect(radius).toBeCloseTo(Math.hypot(900, 500), 4) + }) +}) diff --git a/vue/src/theme.ts b/vue/src/theme.ts new file mode 100644 index 0000000..aa58431 --- /dev/null +++ b/vue/src/theme.ts @@ -0,0 +1,27 @@ +export type Theme = 'light' | 'dark' + +export function resolveInitialTheme(storedTheme: Theme | null, prefersDark: boolean): Theme { + if (storedTheme === 'light' || storedTheme === 'dark') { + return storedTheme + } + return prefersDark ? 'dark' : 'light' +} + +export function toggleTheme(currentTheme: Theme): Theme { + return currentTheme === 'dark' ? 'light' : 'dark' +} + +export function calculateRevealRadius( + originX: number, + originY: number, + viewportWidth: number, + viewportHeight: number, +) { + const distances = [ + Math.hypot(originX, originY), + Math.hypot(viewportWidth - originX, originY), + Math.hypot(originX, viewportHeight - originY), + Math.hypot(viewportWidth - originX, viewportHeight - originY), + ] + return Math.max(...distances) +}