diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts
new file mode 100644
index 0000000..d510808
--- /dev/null
+++ b/frontend/src/app.d.ts
@@ -0,0 +1,25 @@
+// See https://svelte.dev/docs/kit/types#app.d.ts
+// for information about these interfaces
+declare global {
+ namespace App {
+ interface Error {
+ meldung?: string;
+ }
+ interface Locals {
+ benutzer?: {
+ id: string;
+ name: string;
+ rolle: 'planer' | 'mitglied';
+ };
+ haushalt?: {
+ id: string | undefined;
+ name: string;
+ };
+ }
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts
new file mode 100644
index 0000000..708071e
--- /dev/null
+++ b/frontend/src/hooks.server.test.ts
@@ -0,0 +1,146 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock $env/dynamic/private before importing anything
+vi.mock('$env/dynamic/private', () => ({
+ env: { BACKEND_URL: 'http://localhost:8080' }
+}));
+
+// Mock the apiClient
+const mockGet = vi.fn();
+vi.mock('$lib/server/api', () => ({
+ apiClient: () => ({ GET: mockGet })
+}));
+
+describe('auth guard (hooks.server.ts handle)', () => {
+ let handle: any;
+
+ beforeEach(async () => {
+ mockGet.mockReset();
+ const mod = await import('./hooks.server');
+ handle = mod.handle;
+ });
+
+ function createEvent(pathname: string, cookie?: string) {
+ const resolve = vi.fn().mockResolvedValue(new Response('ok'));
+ const event = {
+ url: new URL(`http://localhost${pathname}`),
+ cookies: {
+ get: vi.fn().mockImplementation((name: string) => {
+ if (name === 'JSESSIONID') return cookie;
+ return undefined;
+ })
+ },
+ locals: {} as any,
+ fetch: vi.fn()
+ };
+ return { event, resolve };
+ }
+
+ it('allows public routes without auth', async () => {
+ const { event, resolve } = createEvent('/login');
+ await handle({ event, resolve });
+ expect(resolve).toHaveBeenCalledWith(event);
+ });
+
+ it.each(['/login', '/login/', '/register', '/invite/abc123'])(
+ 'allows public route %s without auth',
+ async (path) => {
+ const { event, resolve } = createEvent(path);
+ await handle({ event, resolve });
+ expect(resolve).toHaveBeenCalledWith(event);
+ }
+ );
+
+ it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])(
+ 'allows static asset %s without auth',
+ async (path) => {
+ const { event, resolve } = createEvent(path);
+ await handle({ event, resolve });
+ expect(resolve).toHaveBeenCalledWith(event);
+ }
+ );
+
+ it('redirects unauthenticated requests to /login with redirect param', async () => {
+ const { event, resolve } = createEvent('/recipes/abc');
+ try {
+ await handle({ event, resolve });
+ expect.unreachable();
+ } catch (e: any) {
+ expect(e.status).toBe(302);
+ expect(e.location).toBe('/login?redirect=%2Frecipes%2Fabc');
+ }
+ });
+
+ it('populates event.locals.benutzer on valid session', async () => {
+ mockGet.mockResolvedValue({
+ data: {
+ data: {
+ id: '123',
+ displayName: 'Max',
+ householdId: 'h1',
+ householdName: 'Familie Müller',
+ householdRole: 'planer',
+ email: 'max@example.com',
+ systemRole: 'user'
+ }
+ },
+ error: undefined
+ });
+
+ const { event, resolve } = createEvent('/planner', 'valid-session');
+ await handle({ event, resolve });
+ expect(event.locals.benutzer).toEqual({
+ id: '123',
+ name: 'Max',
+ rolle: 'planer'
+ });
+ expect(event.locals.haushalt).toEqual({
+ id: 'h1',
+ name: 'Familie Müller'
+ });
+ expect(resolve).toHaveBeenCalledWith(event);
+ });
+
+ it('handles user without household gracefully', async () => {
+ mockGet.mockResolvedValue({
+ data: {
+ data: {
+ id: '456',
+ displayName: 'Neu',
+ householdId: null,
+ householdName: null,
+ householdRole: null,
+ email: 'neu@example.com',
+ systemRole: 'user'
+ }
+ },
+ error: undefined
+ });
+
+ const { event, resolve } = createEvent('/planner', 'valid-session');
+ await handle({ event, resolve });
+ expect(event.locals.benutzer).toEqual({
+ id: '456',
+ name: 'Neu',
+ rolle: 'mitglied'
+ });
+ expect(event.locals.haushalt).toEqual({
+ id: undefined,
+ name: 'Kein Haushalt'
+ });
+ expect(resolve).toHaveBeenCalledWith(event);
+ });
+
+ it('redirects to /login with redirect param when session validation fails', async () => {
+ mockGet.mockResolvedValue({ data: undefined, error: { status: 401 } });
+
+ const { event, resolve } = createEvent('/planner', 'bad-session');
+ try {
+ await handle({ event, resolve });
+ expect.unreachable();
+ } catch (e: any) {
+ expect(e.status).toBe(302);
+ expect(e.location).toBe('/login?redirect=%2Fplanner');
+ }
+ });
+});
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
new file mode 100644
index 0000000..0f60418
--- /dev/null
+++ b/frontend/src/hooks.server.ts
@@ -0,0 +1,50 @@
+import type { Handle } from '@sveltejs/kit';
+import { redirect } from '@sveltejs/kit';
+import { apiClient } from '$lib/server/api';
+
+const PUBLIC_ROUTES = ['/login', '/register', '/invite'];
+
+const STATIC_PREFIXES = ['/_app/', '/favicon'];
+
+function isPublicRoute(pathname: string): boolean {
+ if (STATIC_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
+ return true;
+ }
+ return PUBLIC_ROUTES.some((route) => pathname === route || pathname.startsWith(route + '/'));
+}
+
+function loginRedirect(pathname: string): never {
+ const target = '/login?redirect=' + encodeURIComponent(pathname);
+ redirect(302, target);
+}
+
+export const handle: Handle = async ({ event, resolve }) => {
+ if (isPublicRoute(event.url.pathname)) {
+ return resolve(event);
+ }
+
+ const sessionCookie = event.cookies.get('JSESSIONID');
+ if (!sessionCookie) {
+ loginRedirect(event.url.pathname);
+ }
+
+ const api = apiClient(event.fetch);
+ const { data, error } = await api.GET('/v1/auth/me');
+
+ if (error || !data?.data) {
+ loginRedirect(event.url.pathname);
+ }
+
+ const user = data.data;
+ event.locals.benutzer = {
+ id: user.id!,
+ name: user.displayName!,
+ rolle: (user.householdRole as 'planer' | 'mitglied') ?? 'mitglied'
+ };
+ event.locals.haushalt = {
+ id: user.householdId ?? undefined,
+ name: user.householdName ?? 'Kein Haushalt'
+ };
+
+ return resolve(event);
+};
diff --git a/frontend/src/lib/nav/AppShell.svelte b/frontend/src/lib/nav/AppShell.svelte
new file mode 100644
index 0000000..e2c8b21
--- /dev/null
+++ b/frontend/src/lib/nav/AppShell.svelte
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+ {@render children?.()}
+
+
+
+
diff --git a/frontend/src/lib/nav/AppShell.test.ts b/frontend/src/lib/nav/AppShell.test.ts
new file mode 100644
index 0000000..24e09b4
--- /dev/null
+++ b/frontend/src/lib/nav/AppShell.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import AppShell from './AppShell.svelte';
+
+vi.mock('$app/stores', async () => {
+ const { readable } = await import('svelte/store');
+ return {
+ page: readable({ url: new URL('http://localhost/planner') })
+ };
+});
+
+describe('AppShell', () => {
+ const defaultProps = { appName: 'Mealprep', householdName: 'Familie Müller' };
+
+ it('renders the DesktopSidebar', () => {
+ render(AppShell, { props: defaultProps });
+ expect(screen.getByTestId('variety-widget-slot')).toBeInTheDocument();
+ });
+
+ it('renders the MobileTabBar nav', () => {
+ render(AppShell, { props: defaultProps });
+ const navs = screen.getAllByLabelText('Hauptnavigation');
+ expect(navs.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('renders a main content area', () => {
+ render(AppShell, { props: defaultProps });
+ expect(screen.getByRole('main')).toBeInTheDocument();
+ });
+
+ it('renders all navigation links from all nav variants', () => {
+ render(AppShell, { props: defaultProps });
+ const links = screen.getAllByRole('link');
+ // Mobile: 4, Tablet: 4, Desktop: 5 = 13 total
+ expect(links).toHaveLength(13);
+ });
+});
diff --git a/frontend/src/lib/nav/DesktopSidebar.svelte b/frontend/src/lib/nav/DesktopSidebar.svelte
new file mode 100644
index 0000000..9865bab
--- /dev/null
+++ b/frontend/src/lib/nav/DesktopSidebar.svelte
@@ -0,0 +1,43 @@
+
+
+
diff --git a/frontend/src/lib/nav/DesktopSidebar.test.ts b/frontend/src/lib/nav/DesktopSidebar.test.ts
new file mode 100644
index 0000000..16e2421
--- /dev/null
+++ b/frontend/src/lib/nav/DesktopSidebar.test.ts
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import DesktopSidebar from './DesktopSidebar.svelte';
+
+vi.mock('$app/stores', async () => {
+ const { readable } = await import('svelte/store');
+ return {
+ page: readable({ url: new URL('http://localhost/planner') })
+ };
+});
+
+describe('DesktopSidebar', () => {
+ it('renders the app name', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ expect(screen.getByText('Mealprep')).toBeInTheDocument();
+ });
+
+ it('renders the household name', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ expect(screen.getByText('Familie Müller')).toBeInTheDocument();
+ });
+
+ it('renders Plan section with 3 items', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ expect(screen.getByText('Plan')).toBeInTheDocument();
+ expect(screen.getByText('Planer')).toBeInTheDocument();
+ expect(screen.getByText('Rezepte')).toBeInTheDocument();
+ expect(screen.getByText('Einkauf')).toBeInTheDocument();
+ });
+
+ it('renders Household section with 2 items', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ expect(screen.getByText('Haushalt')).toBeInTheDocument();
+ expect(screen.getByText('Mitglieder')).toBeInTheDocument();
+ expect(screen.getByText('Einstellungen')).toBeInTheDocument();
+ });
+
+ it('has 5 navigation links total', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ const links = screen.getAllByRole('link');
+ expect(links).toHaveLength(5);
+ });
+
+ it('marks active item with aria-current="page"', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ const plannerLink = screen.getByRole('link', { name: /planer/i });
+ expect(plannerLink).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('non-active items do not have aria-current', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ const recipesLink = screen.getByRole('link', { name: /rezepte/i });
+ expect(recipesLink).not.toHaveAttribute('aria-current');
+ });
+
+ it('renders a variety widget slot area', () => {
+ render(DesktopSidebar, { props: { appName: 'Mealprep', householdName: 'Familie Müller' } });
+ const widget = screen.getByTestId('variety-widget-slot');
+ expect(widget).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/nav/MobileTabBar.routes.test.ts b/frontend/src/lib/nav/MobileTabBar.routes.test.ts
new file mode 100644
index 0000000..36b73a7
--- /dev/null
+++ b/frontend/src/lib/nav/MobileTabBar.routes.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+
+const { pageStore } = vi.hoisted(() => {
+ const { writable } = require('svelte/store');
+ const pageStore = writable({ url: new URL('http://localhost/planner') });
+ return { pageStore };
+});
+
+vi.mock('$app/stores', () => ({
+ page: pageStore
+}));
+
+import MobileTabBar from './MobileTabBar.svelte';
+
+describe('MobileTabBar active state per route', () => {
+ it.each([
+ ['/planner', 'Planer'],
+ ['/recipes', 'Rezepte'],
+ ['/shopping', 'Einkauf'],
+ ['/settings', 'Einstellungen']
+ ])('on %s, %s is active and others are not', (route, expectedActiveLabel) => {
+ pageStore.set({ url: new URL(`http://localhost${route}`) });
+ const { unmount } = render(MobileTabBar);
+
+ const activeLink = screen.getByRole('link', { name: new RegExp(expectedActiveLabel) });
+ expect(activeLink).toHaveAttribute('aria-current', 'page');
+
+ const allLinks = screen.getAllByRole('link');
+ const inactiveLinks = allLinks.filter((link) => !link.textContent?.includes(expectedActiveLabel));
+ for (const link of inactiveLinks) {
+ expect(link).not.toHaveAttribute('aria-current');
+ }
+
+ unmount();
+ });
+});
diff --git a/frontend/src/lib/nav/MobileTabBar.svelte b/frontend/src/lib/nav/MobileTabBar.svelte
new file mode 100644
index 0000000..d0b336a
--- /dev/null
+++ b/frontend/src/lib/nav/MobileTabBar.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/frontend/src/lib/nav/MobileTabBar.test.ts b/frontend/src/lib/nav/MobileTabBar.test.ts
new file mode 100644
index 0000000..3e7f023
--- /dev/null
+++ b/frontend/src/lib/nav/MobileTabBar.test.ts
@@ -0,0 +1,55 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import MobileTabBar from './MobileTabBar.svelte';
+
+vi.mock('$app/stores', async () => {
+ const { readable } = await import('svelte/store');
+ return {
+ page: readable({ url: new URL('http://localhost/planner') })
+ };
+});
+
+describe('MobileTabBar', () => {
+ it('renders 4 nav items', () => {
+ render(MobileTabBar);
+ const links = screen.getAllByRole('link');
+ expect(links).toHaveLength(4);
+ });
+
+ it('renders correct labels', () => {
+ render(MobileTabBar);
+ expect(screen.getByText('Planer')).toBeInTheDocument();
+ expect(screen.getByText('Rezepte')).toBeInTheDocument();
+ expect(screen.getByText('Einkauf')).toBeInTheDocument();
+ expect(screen.getByText('Einstellungen')).toBeInTheDocument();
+ });
+
+ it('links have correct hrefs', () => {
+ render(MobileTabBar);
+ const links = screen.getAllByRole('link');
+ expect(links[0]).toHaveAttribute('href', '/planner');
+ expect(links[1]).toHaveAttribute('href', '/recipes');
+ expect(links[2]).toHaveAttribute('href', '/shopping');
+ expect(links[3]).toHaveAttribute('href', '/settings');
+ });
+
+ it('marks active item with aria-current="page"', () => {
+ render(MobileTabBar);
+ const plannerLink = screen.getByRole('link', { name: /planer/i });
+ expect(plannerLink).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('renders icons for each nav item', () => {
+ render(MobileTabBar);
+ expect(screen.getByText('📅')).toBeInTheDocument();
+ expect(screen.getByText('📖')).toBeInTheDocument();
+ expect(screen.getByText('🛒')).toBeInTheDocument();
+ expect(screen.getByText('⚙️')).toBeInTheDocument();
+ });
+
+ it('non-active items do not have aria-current', () => {
+ render(MobileTabBar);
+ const recipesLink = screen.getByRole('link', { name: /rezepte/i });
+ expect(recipesLink).not.toHaveAttribute('aria-current');
+ });
+});
diff --git a/frontend/src/lib/nav/TabletNavBar.svelte b/frontend/src/lib/nav/TabletNavBar.svelte
new file mode 100644
index 0000000..85f119a
--- /dev/null
+++ b/frontend/src/lib/nav/TabletNavBar.svelte
@@ -0,0 +1,23 @@
+
+
+
diff --git a/frontend/src/lib/nav/TabletNavBar.test.ts b/frontend/src/lib/nav/TabletNavBar.test.ts
new file mode 100644
index 0000000..2b32f43
--- /dev/null
+++ b/frontend/src/lib/nav/TabletNavBar.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/svelte';
+import TabletNavBar from './TabletNavBar.svelte';
+
+vi.mock('$app/stores', async () => {
+ const { readable } = await import('svelte/store');
+ return {
+ page: readable({ url: new URL('http://localhost/planner') })
+ };
+});
+
+describe('TabletNavBar', () => {
+ it('renders 4 nav items', () => {
+ render(TabletNavBar);
+ const links = screen.getAllByRole('link');
+ expect(links).toHaveLength(4);
+ });
+
+ it('renders correct labels', () => {
+ render(TabletNavBar);
+ expect(screen.getByText('Planer')).toBeInTheDocument();
+ expect(screen.getByText('Rezepte')).toBeInTheDocument();
+ expect(screen.getByText('Einkauf')).toBeInTheDocument();
+ expect(screen.getByText('Einstellungen')).toBeInTheDocument();
+ });
+
+ it('marks active item with aria-current="page"', () => {
+ render(TabletNavBar);
+ const plannerLink = screen.getByRole('link', { name: /planer/i });
+ expect(plannerLink).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('renders as horizontal pill navigation', () => {
+ render(TabletNavBar);
+ const nav = screen.getByLabelText('Hauptnavigation');
+ expect(nav).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/lib/nav/nav.test.ts b/frontend/src/lib/nav/nav.test.ts
new file mode 100644
index 0000000..0653e82
--- /dev/null
+++ b/frontend/src/lib/nav/nav.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect } from 'vitest';
+import { mobileNavItems, desktopNavSections, isActiveRoute } from './nav';
+
+describe('nav config', () => {
+ describe('mobileNavItems', () => {
+ it('has exactly 4 items', () => {
+ expect(mobileNavItems).toHaveLength(4);
+ });
+
+ it('contains Planner, Recipes, Shopping, Settings in order', () => {
+ const labels = mobileNavItems.map((item) => item.label);
+ expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf', 'Einstellungen']);
+ });
+
+ it('each item has href, label, and icon', () => {
+ for (const item of mobileNavItems) {
+ expect(item).toHaveProperty('href');
+ expect(item).toHaveProperty('label');
+ expect(item).toHaveProperty('icon');
+ expect(item.href).toMatch(/^\//);
+ }
+ });
+ });
+
+ describe('desktopNavSections', () => {
+ it('has 2 sections: Plan and Household', () => {
+ expect(desktopNavSections).toHaveLength(2);
+ expect(desktopNavSections[0].title).toBe('Plan');
+ expect(desktopNavSections[1].title).toBe('Haushalt');
+ });
+
+ it('Plan section has Planner, Recipes, Shopping', () => {
+ const labels = desktopNavSections[0].items.map((item) => item.label);
+ expect(labels).toEqual(['Planer', 'Rezepte', 'Einkauf']);
+ });
+
+ it('Household section has Members, Settings', () => {
+ const labels = desktopNavSections[1].items.map((item) => item.label);
+ expect(labels).toEqual(['Mitglieder', 'Einstellungen']);
+ });
+ });
+
+ describe('isActiveRoute', () => {
+ it('matches exact route', () => {
+ expect(isActiveRoute('/planner', '/planner')).toBe(true);
+ });
+
+ it('matches sub-route', () => {
+ expect(isActiveRoute('/planner', '/planner/week')).toBe(true);
+ });
+
+ it('does not match route with similar prefix', () => {
+ expect(isActiveRoute('/settings', '/settings-advanced')).toBe(false);
+ });
+
+ it('does not match unrelated route', () => {
+ expect(isActiveRoute('/planner', '/recipes')).toBe(false);
+ });
+ });
+});
diff --git a/frontend/src/lib/nav/nav.ts b/frontend/src/lib/nav/nav.ts
new file mode 100644
index 0000000..883bdcb
--- /dev/null
+++ b/frontend/src/lib/nav/nav.ts
@@ -0,0 +1,39 @@
+export interface NavItem {
+ href: string;
+ label: string;
+ icon: string;
+}
+
+export interface NavSection {
+ title: string;
+ items: NavItem[];
+}
+
+export const mobileNavItems: NavItem[] = [
+ { href: '/planner', label: 'Planer', icon: '📅' },
+ { href: '/recipes', label: 'Rezepte', icon: '📖' },
+ { href: '/shopping', label: 'Einkauf', icon: '🛒' },
+ { href: '/settings', label: 'Einstellungen', icon: '⚙️' }
+];
+
+export function isActiveRoute(href: string, pathname: string): boolean {
+ return pathname === href || pathname.startsWith(href + '/');
+}
+
+export const desktopNavSections: NavSection[] = [
+ {
+ title: 'Plan',
+ items: [
+ { href: '/planner', label: 'Planer', icon: '📅' },
+ { href: '/recipes', label: 'Rezepte', icon: '📖' },
+ { href: '/shopping', label: 'Einkauf', icon: '🛒' }
+ ]
+ },
+ {
+ title: 'Haushalt',
+ items: [
+ { href: '/members', label: 'Mitglieder', icon: '👥' },
+ { href: '/settings', label: 'Einstellungen', icon: '⚙️' }
+ ]
+ }
+];
diff --git a/frontend/src/routes/(app)/+layout.server.ts b/frontend/src/routes/(app)/+layout.server.ts
new file mode 100644
index 0000000..e64b781
--- /dev/null
+++ b/frontend/src/routes/(app)/+layout.server.ts
@@ -0,0 +1,8 @@
+import type { LayoutServerLoad } from './$types';
+
+export const load: LayoutServerLoad = async ({ locals }) => {
+ return {
+ benutzer: locals.benutzer!,
+ haushalt: locals.haushalt!
+ };
+};
diff --git a/frontend/src/routes/(app)/+layout.svelte b/frontend/src/routes/(app)/+layout.svelte
new file mode 100644
index 0000000..9f67711
--- /dev/null
+++ b/frontend/src/routes/(app)/+layout.svelte
@@ -0,0 +1,9 @@
+
+
+
+ {@render children()}
+
diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte
new file mode 100644
index 0000000..a4722af
--- /dev/null
+++ b/frontend/src/routes/(app)/members/+page.svelte
@@ -0,0 +1 @@
+Mitglieder
diff --git a/frontend/src/routes/(app)/planner/+page.svelte b/frontend/src/routes/(app)/planner/+page.svelte
new file mode 100644
index 0000000..dbb8271
--- /dev/null
+++ b/frontend/src/routes/(app)/planner/+page.svelte
@@ -0,0 +1 @@
+Planer
diff --git a/frontend/src/routes/(app)/recipes/+page.svelte b/frontend/src/routes/(app)/recipes/+page.svelte
new file mode 100644
index 0000000..1faa0a3
--- /dev/null
+++ b/frontend/src/routes/(app)/recipes/+page.svelte
@@ -0,0 +1 @@
+Rezepte
diff --git a/frontend/src/routes/(app)/settings/+page.svelte b/frontend/src/routes/(app)/settings/+page.svelte
new file mode 100644
index 0000000..f369397
--- /dev/null
+++ b/frontend/src/routes/(app)/settings/+page.svelte
@@ -0,0 +1 @@
+Einstellungen
diff --git a/frontend/src/routes/(app)/shopping/+page.svelte b/frontend/src/routes/(app)/shopping/+page.svelte
new file mode 100644
index 0000000..158e40e
--- /dev/null
+++ b/frontend/src/routes/(app)/shopping/+page.svelte
@@ -0,0 +1 @@
+Einkaufsliste
diff --git a/frontend/src/routes/(public)/+layout.svelte b/frontend/src/routes/(public)/+layout.svelte
new file mode 100644
index 0000000..d90949a
--- /dev/null
+++ b/frontend/src/routes/(public)/+layout.svelte
@@ -0,0 +1,12 @@
+
+
+
+
+ Mealprep
+
+
+ {@render children()}
+
+
diff --git a/frontend/src/routes/(public)/login/+page.svelte b/frontend/src/routes/(public)/login/+page.svelte
new file mode 100644
index 0000000..7b4a440
--- /dev/null
+++ b/frontend/src/routes/(public)/login/+page.svelte
@@ -0,0 +1,2 @@
+Anmelden
+Login-Formular folgt.
diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts
new file mode 100644
index 0000000..7985791
--- /dev/null
+++ b/frontend/src/routes/+page.server.ts
@@ -0,0 +1,6 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load: PageServerLoad = async () => {
+ redirect(302, '/planner');
+};
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
new file mode 100644
index 0000000..36ec5ff
--- /dev/null
+++ b/frontend/src/routes/+page.svelte
@@ -0,0 +1 @@
+Weiterleitung...
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
new file mode 100644
index 0000000..5b032c7
--- /dev/null
+++ b/frontend/vite.config.ts
@@ -0,0 +1,18 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vitest/config';
+import tailwindcss from '@tailwindcss/vite';
+
+export default defineConfig({
+ plugins: [tailwindcss(), sveltekit()],
+ test: {
+ include: ['src/**/*.{test,spec}.{js,ts}'],
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: ['src/test-setup.ts']
+ },
+ // Required for vitest: resolves Svelte to client entry (not server).
+ // SvelteKit's plugin overrides this for SSR builds — verified safe.
+ resolve: {
+ conditions: ['browser']
+ }
+});