Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions apps/web/src/features/dashboard/application-shell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Verifies the application shell exposes accessible responsive navigation and active state.
import { RouterProvider } from '@tanstack/react-router';
import { cleanup, render, screen, within } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';

import { router } from '../../router';

afterEach(cleanup);

describe('application shell', () => {
it('renders desktop and mobile navigation with the current page selected', async () => {
await router.navigate({ to: '/dashboard' });
render(<RouterProvider router={router} />);

expect(screen.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

const navigationRegions = screen.getAllByRole('navigation', {
name: /primary navigation/i,
});
expect(navigationRegions).toHaveLength(2);

for (const navigation of navigationRegions) {
expect(
within(navigation).getByRole('link', { name: 'Dashboard' }),
).toHaveAttribute('aria-current', 'page');
expect(
within(navigation).getByRole('link', { name: 'Routines' }),
).toHaveAttribute('href', '/routines');
expect(
within(navigation).getByRole('link', { name: 'History' }),
).toHaveAttribute('href', '/history');
expect(
within(navigation).getByRole('link', { name: 'Settings' }),
).toHaveAttribute('href', '/settings');
}
});
});
133 changes: 133 additions & 0 deletions apps/web/src/features/dashboard/application-shell.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Provides the responsive desktop and mobile navigation frame for application pages.
import {
Dumbbell,
History,
LayoutDashboard,
Settings,
type LucideIcon,
} from 'lucide-react';
import { Link } from '@tanstack/react-router';
import type { ReactNode } from 'react';

import wordmarkUrl from '../../assets/goforlift-wordmark.png';
import { cn } from '../../lib/utils';

const navigationItems = [
{ href: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
{ href: '/routines', icon: Dumbbell, label: 'Routines' },
{ href: '/history', icon: History, label: 'History' },
{ href: '/settings', icon: Settings, label: 'Settings' },
] as const;

type ApplicationShellProps = {
children: ReactNode;
};

export function ApplicationShell({ children }: ApplicationShellProps) {
return (
<div
className={cn(
'min-h-screen bg-background text-foreground',
'lg:grid lg:grid-cols-[256px_minmax(0,1fr)]',
)}
>
<aside className="hidden border-r bg-surface lg:flex lg:min-h-screen lg:flex-col">
<Brand className="px-7 py-8" />
<Navigation className="flex-1 space-y-1 px-4" />
</aside>

<div className="min-w-0">
<header
className={cn(
'sticky top-0 z-20 flex h-16 items-center px-5',
'border-b bg-surface/95 backdrop-blur',
'lg:hidden',
)}
>
<Brand />
</header>
<main
className={cn(
'mx-auto w-full max-w-[1440px] px-5 py-7 pb-28',
'sm:px-8',
'lg:px-10 lg:py-9 lg:pb-10',
)}
>
{children}
</main>
</div>

<Navigation
className={cn(
'fixed inset-x-0 bottom-0 z-30 grid grid-cols-4 px-2 pt-2',
'border-t bg-surface/95 backdrop-blur',
'pb-[max(0.5rem,env(safe-area-inset-bottom))]',
'lg:hidden',
)}
/>
</div>
);
}

function Brand({ className }: { className?: string }) {
return (
<Link
aria-label="GoForLift dashboard"
className={cn('block', className)}
to="/dashboard"
>
<img alt="GoForLift" className="h-auto w-[132px]" src={wordmarkUrl} />
</Link>
);
}

type NavigationProps = {
className?: string;
};

function Navigation({ className }: NavigationProps) {
return (
<nav aria-label="Primary navigation" className={className}>
{navigationItems.map((item) => (
<NavigationItem
href={item.href}
icon={item.icon}
key={item.href}
label={item.label}
/>
))}
</nav>
);
}

type NavigationItemProps = {
href: (typeof navigationItems)[number]['href'];
icon: LucideIcon;
label: string;
};

function NavigationItem({ href, icon: Icon, label }: NavigationItemProps) {
return (
<Link
activeOptions={{ exact: true }}
activeProps={{
className:
'bg-primary/15 text-primary hover:bg-primary/20 hover:text-primary',
}}
className={cn(
'flex min-h-14 flex-col items-center justify-center gap-1 rounded-md px-1 text-[11px]',
'transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'lg:min-h-11 lg:flex-row lg:justify-start lg:gap-3 lg:px-4 lg:text-sm lg:font-medium',
)}
inactiveProps={{
className:
'text-muted-foreground hover:bg-accent hover:text-foreground',
}}
to={href}
>
<Icon aria-hidden="true" className="size-5 lg:size-[18px]" />
<span>{label}</span>
</Link>
);
}
18 changes: 4 additions & 14 deletions apps/web/src/features/dashboard/dashboard-landing.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
// Presents the temporary shared landing state before the full dashboard is designed.
// Presents the dashboard heading inside the shared responsive application shell.
import { SectionLanding } from './section-landing';

export function DashboardLanding() {
return (
<main className="flex min-h-screen items-center justify-center bg-background px-6 text-foreground">
<section className="max-w-md text-center">
<p className="text-sm font-medium uppercase tracking-[0.2em] text-primary">
Dashboard
</p>
<h1 className="mt-3 text-3xl font-semibold">Welcome to GoForLift</h1>
<p className="mt-4 text-muted-foreground">
Your session is ready. The full dashboard is the next step.
</p>
</section>
</main>
);
return <SectionLanding title="Dashboard" />;
}
16 changes: 16 additions & 0 deletions apps/web/src/features/dashboard/section-landing.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Presents a minimal content heading inside the shared application shell.
import { ApplicationShell } from './application-shell';

type SectionLandingProps = {
title: string;
};

export function SectionLanding({ title }: SectionLandingProps) {
return (
<ApplicationShell>
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
{title}
</h1>
</ApplicationShell>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
// Combines conditional class names and resolves conflicting Tailwind utilities for reusable components.
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

// `cn` means "class names"; this helper keeps conditional styles readable and
// ensures later Tailwind utilities correctly override conflicting earlier ones.
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
60 changes: 57 additions & 3 deletions apps/web/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as HistoryRouteImport } from './routes/history'
import { Route as RoutinesRouteImport } from './routes/routines'
import { Route as SettingsRouteImport } from './routes/settings'

const IndexRoute = IndexRouteImport.update({
id: '/',
Expand All @@ -22,31 +25,58 @@ const DashboardRoute = DashboardRouteImport.update({
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
const HistoryRoute = HistoryRouteImport.update({
id: '/history',
path: '/history',
getParentRoute: () => rootRouteImport,
} as any)
const RoutinesRoute = RoutinesRouteImport.update({
id: '/routines',
path: '/routines',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/history': typeof HistoryRoute
'/routines': typeof RoutinesRoute
'/settings': typeof SettingsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/history': typeof HistoryRoute
'/routines': typeof RoutinesRoute
'/settings': typeof SettingsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/dashboard': typeof DashboardRoute
'/history': typeof HistoryRoute
'/routines': typeof RoutinesRoute
'/settings': typeof SettingsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/dashboard'
fullPaths: '/' | '/dashboard' | '/history' | '/routines' | '/settings'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/dashboard'
id: '__root__' | '/' | '/dashboard'
to: '/' | '/dashboard' | '/history' | '/routines' | '/settings'
id: '__root__' | '/' | '/dashboard' | '/history' | '/routines' | '/settings'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DashboardRoute: typeof DashboardRoute
HistoryRoute: typeof HistoryRoute
RoutinesRoute: typeof RoutinesRoute
SettingsRoute: typeof SettingsRoute
}

declare module '@tanstack/react-router' {
Expand All @@ -65,12 +95,36 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
'/history': {
id: '/history'
path: '/history'
fullPath: '/history'
preLoaderRoute: typeof HistoryRouteImport
parentRoute: typeof rootRouteImport
}
'/routines': {
id: '/routines'
path: '/routines'
fullPath: '/routines'
preLoaderRoute: typeof RoutinesRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DashboardRoute: DashboardRoute,
HistoryRoute: HistoryRoute,
RoutinesRoute: RoutinesRoute,
SettingsRoute: SettingsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/routes/history.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Maps the history destination into the shared responsive application shell.
import { createFileRoute } from '@tanstack/react-router';

import { SectionLanding } from '../features/dashboard/section-landing';

export const Route = createFileRoute('/history')({
component: () => <SectionLanding title="History" />,
});
4 changes: 3 additions & 1 deletion apps/web/src/routes/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ describe('welcome page', () => {

fireEvent.click(screen.getByRole('button', { name: /continue as guest/i }));

expect(await screen.findByText(/your session is ready/i)).toBeVisible();
expect(
await screen.findByRole('heading', { name: 'Dashboard' }),
).toBeVisible();
expect(sessionStorage.getItem('goforlift.guest')).toBe('true');
expect(router.state.location.pathname).toBe('/dashboard');

Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/routes/routines.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Maps the routines destination into the shared responsive application shell.
import { createFileRoute } from '@tanstack/react-router';

import { SectionLanding } from '../features/dashboard/section-landing';

export const Route = createFileRoute('/routines')({
component: () => <SectionLanding title="Routines" />,
});
8 changes: 8 additions & 0 deletions apps/web/src/routes/settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Maps the settings destination into the shared responsive application shell.
import { createFileRoute } from '@tanstack/react-router';

import { SectionLanding } from '../features/dashboard/section-landing';

export const Route = createFileRoute('/settings')({
component: () => <SectionLanding title="Settings" />,
});
20 changes: 10 additions & 10 deletions apps/web/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@

:root {
color-scheme: dark;
--background: oklch(0.145 0.01 285);
--background: oklch(0.14 0.018 255);
--foreground: oklch(0.96 0.01 285);
--surface: oklch(0.205 0.015 285);
--surface-elevated: oklch(0.26 0.02 285);
--primary: oklch(0.72 0.16 175);
--primary-foreground: oklch(0.15 0.02 175);
--secondary: oklch(0.26 0.02 285);
--surface: oklch(0.18 0.022 255);
--surface-elevated: oklch(0.22 0.025 255);
--primary: oklch(0.59 0.22 27);
--primary-foreground: oklch(0.98 0.005 27);
--secondary: oklch(0.22 0.025 255);
--secondary-foreground: oklch(0.96 0.01 285);
--accent: oklch(0.3 0.025 285);
--accent: oklch(0.24 0.025 255);
--accent-foreground: oklch(0.96 0.01 285);
--muted-foreground: oklch(0.7 0.02 285);
--border: oklch(0.32 0.02 285);
--input: oklch(0.32 0.02 285);
--muted-foreground: oklch(0.7 0.018 255);
--border: oklch(0.3 0.025 255);
--input: oklch(0.3 0.025 255);
--ring: var(--primary);
--destructive: oklch(0.65 0.2 25);
}
Expand Down
Loading