diff --git a/web/src/app/layout-header-style.ts b/web/src/app/layout-header-style.ts new file mode 100644 index 00000000..3ae7e8fc --- /dev/null +++ b/web/src/app/layout-header-style.ts @@ -0,0 +1,10 @@ +import { cn } from '@/shared/lib/utils' + +export const APP_HEADER_BASE_CLASS_NAME = + 'sticky top-0 z-50 flex items-center justify-between border-b bg-white px-6 py-4 transition-shadow duration-200 md:px-12' + +export const APP_HEADER_ELEVATED_CLASS_NAME = 'shadow-[0_10px_24px_-20px_rgba(15,23,42,0.32)]' + +export function getAppHeaderClassName(isElevated: boolean): string { + return cn(APP_HEADER_BASE_CLASS_NAME, isElevated && APP_HEADER_ELEVATED_CLASS_NAME) +} diff --git a/web/src/app/layout-main-content.ts b/web/src/app/layout-main-content.ts new file mode 100644 index 00000000..28dedc11 --- /dev/null +++ b/web/src/app/layout-main-content.ts @@ -0,0 +1,45 @@ +export const LANDING_MAIN_CLASS_NAME = 'flex-1 relative z-10' +export const DEFAULT_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-6 py-10 md:px-12' +export const CENTERED_MAIN_CLASS_NAME = 'flex-1 relative z-10 px-4 py-8 sm:px-6 md:px-8 md:py-10 lg:px-10 xl:px-14 2xl:px-20' +export const CENTERED_SEARCH_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' +export const CENTERED_DASHBOARD_CONTENT_CLASS_NAME = 'mx-auto w-full max-w-[1200px]' + +interface AppMainContentLayout { + mainClassName: string + contentClassName: string +} + +export function resolveAppMainContentPathname( + pathname: string, + resolvedPathname?: string, +): string { + return resolvedPathname ?? pathname +} + +export function getAppMainContentLayout(pathname: string): AppMainContentLayout { + if (pathname === '/') { + return { + mainClassName: LANDING_MAIN_CLASS_NAME, + contentClassName: '', + } + } + + if (pathname === '/search') { + return { + mainClassName: CENTERED_MAIN_CLASS_NAME, + contentClassName: CENTERED_SEARCH_CONTENT_CLASS_NAME, + } + } + + if (pathname === '/dashboard' || pathname.startsWith('/dashboard/')) { + return { + mainClassName: CENTERED_MAIN_CLASS_NAME, + contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, + } + } + + return { + mainClassName: DEFAULT_MAIN_CLASS_NAME, + contentClassName: '', + } +} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 613c2cdf..5c703e72 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,9 +1,11 @@ -import { Suspense } from 'react' +import { Suspense, useEffect, useState } from 'react' import { Outlet, Link, useRouterState } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { useAuth } from '@/features/auth/use-auth' import { LanguageSwitcher } from '@/shared/components/language-switcher' import { UserMenu } from '@/shared/components/user-menu' +import { getAppHeaderClassName } from './layout-header-style' +import { getAppMainContentLayout, resolveAppMainContentPathname } from './layout-main-content' /** * Application shell shared by all routed pages. @@ -13,8 +15,29 @@ import { UserMenu } from '@/shared/components/user-menu' */ export function Layout() { const { t } = useTranslation() - const pathname = useRouterState({ select: (s) => s.location.pathname }) + const { pathname, resolvedPathname } = useRouterState({ + select: (s) => ({ + pathname: s.location.pathname, + resolvedPathname: s.resolvedLocation?.pathname, + }), + }) const { user, isLoading } = useAuth() + const [isHeaderElevated, setIsHeaderElevated] = useState(false) + const contentLayoutPathname = resolveAppMainContentPathname(pathname, resolvedPathname) + const mainContentLayout = getAppMainContentLayout(contentLayoutPathname) + + useEffect(() => { + const updateHeaderElevation = () => { + setIsHeaderElevated(window.scrollY > 0) + } + + updateHeaderElevation() + window.addEventListener('scroll', updateHeaderElevation, { passive: true }) + + return () => { + window.removeEventListener('scroll', updateHeaderElevation) + } + }, []) const navItems: Array<{ label: string @@ -36,7 +59,7 @@ export function Layout() { } return ( -
+
{/* Decorative gradient orb */}
{/* Header */} -
+
SkillHub @@ -64,7 +87,7 @@ export function Layout() { className={ active ? 'px-4 py-1.5 rounded-full bg-brand-gradient text-white shadow-sm' - : 'hover:opacity-80 transition-opacity' + : 'hover:opacity-80 transition-opacity duration-150' } > {item.label} @@ -90,7 +113,7 @@ export function Layout() {
{/* Main content */} -
+
@@ -100,7 +123,9 @@ export function Layout() {
} > - +
+ +
diff --git a/web/src/app/page-shell-style.ts b/web/src/app/page-shell-style.ts new file mode 100644 index 00000000..dc8308c9 --- /dev/null +++ b/web/src/app/page-shell-style.ts @@ -0,0 +1 @@ +export const APP_SHELL_PAGE_CLASS_NAME = 'space-y-8 animate-fade-up' diff --git a/web/src/pages/dashboard.tsx b/web/src/pages/dashboard.tsx index e9daa899..6d9aa45a 100644 --- a/web/src/pages/dashboard.tsx +++ b/web/src/pages/dashboard.tsx @@ -7,6 +7,7 @@ import { canViewGovernanceCenter } from '@/shared/lib/governance-access' import { getHeadlineVersion } from '@/shared/lib/skill-lifecycle' import { TokenList } from '@/features/token/token-list' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' import { limitPreviewItems } from './dashboard-preview' const DASHBOARD_PREVIEW_LIMIT = 5 @@ -26,7 +27,7 @@ export function DashboardPage() { const skillPreview = limitPreviewItems(skillPage?.items ?? [], DASHBOARD_PREVIEW_LIMIT) return ( -
+

{t('dashboard.title')}

diff --git a/web/src/pages/search.tsx b/web/src/pages/search.tsx index 1bfd5e18..6e7c8b64 100644 --- a/web/src/pages/search.tsx +++ b/web/src/pages/search.tsx @@ -12,6 +12,7 @@ import { Pagination } from '@/shared/components/pagination' import { useMyStars, useSearchSkills } from '@/shared/hooks/use-skill-queries' import { normalizeSearchQuery } from '@/shared/lib/search-query' import { Button } from '@/shared/ui/button' +import { APP_SHELL_PAGE_CLASS_NAME } from '@/app/page-shell-style' const PAGE_SIZE = 12 @@ -149,7 +150,7 @@ export function SearchPage() { const resultCount = starredOnly ? filteredStarredSkills.length : (data?.total ?? 0) return ( -

+
{/* Search Bar */}

{currentTab.description} diff --git a/web/test/app/layout-header-style.test.ts b/web/test/app/layout-header-style.test.ts new file mode 100644 index 00000000..f468858b --- /dev/null +++ b/web/test/app/layout-header-style.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest' +import { APP_HEADER_ELEVATED_CLASS_NAME, getAppHeaderClassName } from '../../src/app/layout-header-style' + +describe('getAppHeaderClassName', () => { + it('keeps the header flat before the page starts scrolling', () => { + expect(getAppHeaderClassName(false)).not.toContain(APP_HEADER_ELEVATED_CLASS_NAME) + }) + + it('adds a subtle drop shadow after the header becomes sticky', () => { + expect(getAppHeaderClassName(true)).toContain(APP_HEADER_ELEVATED_CLASS_NAME) + }) +}) diff --git a/web/test/app/layout-main-content.test.ts b/web/test/app/layout-main-content.test.ts new file mode 100644 index 00000000..381e50b3 --- /dev/null +++ b/web/test/app/layout-main-content.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + CENTERED_DASHBOARD_CONTENT_CLASS_NAME, + CENTERED_MAIN_CLASS_NAME, + CENTERED_SEARCH_CONTENT_CLASS_NAME, + DEFAULT_MAIN_CLASS_NAME, + getAppMainContentLayout, + resolveAppMainContentPathname, +} from '../../src/app/layout-main-content' + +describe('getAppMainContentLayout', () => { + it('keeps the landing page full width without the app-shell padding wrapper', () => { + expect(getAppMainContentLayout('/')).toEqual({ + mainClassName: 'flex-1 relative z-10', + contentClassName: '', + }) + }) + + it('centers the search page content with roomier responsive gutters', () => { + const layout = getAppMainContentLayout('/search') + + expect(layout).toEqual({ + mainClassName: CENTERED_MAIN_CLASS_NAME, + contentClassName: CENTERED_SEARCH_CONTENT_CLASS_NAME, + }) + expect(layout.contentClassName).toContain('max-w-[1200px]') + }) + + it('centers all dashboard sub-pages within a slightly narrower content frame', () => { + const layout = getAppMainContentLayout('/dashboard/skills') + + expect(layout).toEqual({ + mainClassName: CENTERED_MAIN_CLASS_NAME, + contentClassName: CENTERED_DASHBOARD_CONTENT_CLASS_NAME, + }) + expect(layout.contentClassName).toContain('max-w-[1200px]') + }) + + it('leaves other non-landing routes on the default full-width app content layout', () => { + expect(getAppMainContentLayout('/space/acme/demo')).toEqual({ + mainClassName: DEFAULT_MAIN_CLASS_NAME, + contentClassName: '', + }) + }) +}) + +describe('resolveAppMainContentPathname', () => { + it('sticks with the last resolved route while the next page is still pending', () => { + expect(resolveAppMainContentPathname('/dashboard', '/search')).toBe('/search') + }) + + it('falls back to the live location when no resolved route is available yet', () => { + expect(resolveAppMainContentPathname('/search')).toBe('/search') + }) +}) diff --git a/web/test/app/page-shell-style.test.ts b/web/test/app/page-shell-style.test.ts new file mode 100644 index 00000000..9c8ec675 --- /dev/null +++ b/web/test/app/page-shell-style.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { APP_SHELL_PAGE_CLASS_NAME } from '../../src/app/page-shell-style' + +describe('APP_SHELL_PAGE_CLASS_NAME', () => { + it('keeps the upward float-in animation on stable app-shell pages', () => { + expect(APP_SHELL_PAGE_CLASS_NAME).toContain('space-y-8') + expect(APP_SHELL_PAGE_CLASS_NAME).toContain('animate-fade-up') + }) +}) diff --git a/web/tsconfig.json b/web/tsconfig.json index 5e1feb43..9a8ff8fe 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -20,5 +20,5 @@ "@/*": ["./src/*"] } }, - "include": ["src"] + "include": ["src", "test"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 44db2323..2ec40cc3 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -17,11 +17,11 @@ export default defineConfig({ }, proxy: { '/api': { - target: 'http://localhost:8080', + target: 'http://10.1.203.231:8080', changeOrigin: true, }, '/oauth2': { - target: 'http://localhost:8080', + target: 'http://10.1.203.231:8080', changeOrigin: true, }, },