Compare commits

..

76 Commits

Author SHA1 Message Date
df95462094 refactor(staples): convert dynamic userEvent import to static in CategorySection test
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:30:19 +02:00
2d6ddf0e48 fix(staples): apply design-system styles to nav links and settings heading
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:29:53 +02:00
73b33ee956 fix(staples): apply design-system button spec to StapleChip (13px, tracking, font-sans)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:28:50 +02:00
8daaa0e21d fix(staples): pass ctx from URL through load function; fix script order in page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:27:43 +02:00
45b7e7b003 fix(staples): add role guard — only planer role can toggle staples
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:25:40 +02:00
3581af2bf9 fix(staples): forward backend error status code instead of always 500
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:25:06 +02:00
21b873b85b fix(staples): validate isStaple is boolean before forwarding to backend
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:24:35 +02:00
65f18cfb43 test(staples): cover API failure fallback in page load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:24:07 +02:00
7b497be1c1 test(staples): add empty categories edge case to StaplesManager
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 09:23:48 +02:00
7979076f5e feat(invite): stub household invite page as onboarding Continue target
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:16:46 +02:00
d68a9d9312 refactor(setup): redirect to /household/staples?ctx=onboarding after household creation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:16:33 +02:00
97175e7d9d feat(staples): add staples page with onboarding and settings layouts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:16:08 +02:00
3550d681dc feat(staples): load categories and ingredients, group by category
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:14:25 +02:00
54df70a442 feat(staples): add PATCH proxy server route for ingredient staple toggle
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:13:02 +02:00
d577e0231c feat(staples): add StaplesManager with optimistic toggle and debounced PATCH
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:11:51 +02:00
376dc03646 feat(staples): add CategorySection component with eyebrow heading and chip row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:07:51 +02:00
7bdc049461 feat(staples): add StapleChip component with aria-pressed toggle and focus ring
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 20:06:32 +02:00
7c66dcad3a refactor(onboarding): clarify test comment and remove unused response mock
HouseholdSetupForm.test.ts: explain that touched+empty drives the $derived
error, not a submit event on the disabled button.
page.server.test.ts: remove unused response key from mockSuccess() —
household creation doesn't set a session cookie.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:32:44 +02:00
01a321caa9 test(onboarding): add ProgressSidebar test for currentStep=3 (all prior steps completed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:31:56 +02:00
2d1604492d feat(onboarding): add max-length validation for household name (100 chars)
Fails fast before the API call with a clear German error message.
Tests boundary: 100 chars accepted, 101 rejected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:31:13 +02:00
3742364956 fix(onboarding): make HouseholdSetupForm subtitle responsive (12px mobile, 14px desktop)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:30:23 +02:00
36dfea34cc fix(onboarding): make HouseholdSetupForm heading responsive and use font-medium
text-[18px] md:text-[28px] matches auth form pattern.
font-medium (500) replaces font-semibold (600) per design system rules.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:29:52 +02:00
66525484a6 fix(onboarding): correct Tailwind arbitrary font-family syntax in HouseholdSetupForm
font-['var(--font-display)'] → font-[var(--font-display)] so Fraunces
display font is applied correctly to the h1.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:29:21 +02:00
e5614ccf30 refactor(onboarding): remove aria-hidden workaround from progress sidebar
Replace getByText with getByRole(heading) in page test to disambiguate
the duplicate "Haushalt benennen" text between sidebar and form.
Revert defaultIgnore change in test-setup.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:28:46 +02:00
6de7f5a9b5 feat(onboarding): add A2 household setup page with responsive progress sidebar layout
Desktop: 300px ProgressSidebar (step 1 active) + flex form area.
Mobile: "Schritt 1 von 3" eyebrow + HouseholdSetupForm.
Also stubs /household/staples as redirect target for A3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:20:02 +02:00
e85a7ca313 feat(onboarding): add household setup page server action and load guard
Creates household via POST /v1/households, redirects to /household/staples.
Load guard redirects users who already have a household to /planner.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:14:39 +02:00
175bfbe7dd feat(onboarding): add HouseholdSetupForm component with disabled-until-valid continue button
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:13:41 +02:00
b9ef06fd73 feat(onboarding): add ProgressSidebar component with 3-step active/completed/future states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:08:38 +02:00
09333ccc0a test(auth): verify security context is stored in session after login and signup
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:55:25 +02:00
93ce1eaeac refactor(auth): add comments, clearContext on logout, explain session auth
- Add comment to SecurityConfig explaining why CSRF is disabled
- Add SecurityContextHolder.clearContext() to logout for clean thread state
- Add Javadoc on authenticateInSession() explaining manual session setup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:52:16 +02:00
61249af086 feat(auth): add secure flag to JSESSIONID cookie and test JSESSIONID cookie setting
- Add secure: true to cookies.set() in login and signup actions
- Add tests verifying JSESSIONID is forwarded to browser on successful
  login and signup

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:50:34 +02:00
16f0feb8d5 fix(auth): fix mock responses in tests and block open redirect in login
- Add response object to mockSuccess() in login and signup tests so
  response.headers.get() no longer throws
- Validate ?redirect= param: must start with / and not // to prevent
  redirecting users to external domains

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 18:48:48 +02:00
0aa65214fc fix(auth): resolve broken signup/login flow end-to-end
Three root causes fixed:

1. CSRF blocked all backend POSTs — Spring Security's CSRF filter ran
   before permitAll() authorization, returning 401 for signup and login.
   Disabled CSRF since SvelteKit is the only client (never the browser
   directly) and handles its own CSRF via Origin header checks.

2. Login/signup didn't establish Spring Security authentication — they
   stored email in the HTTP session manually but never set the
   SecurityContext, so Principal in /v1/auth/me was always null and
   hooks.server.ts redirected every authenticated request to /login.
   Fixed with authenticateInSession() helper that sets and persists
   the SecurityContext under SPRING_SECURITY_CONTEXT_KEY. Login also
   now invalidates the old session before creating a new one to prevent
   session fixation.

3. redirect() missing throw in hooks.server.ts, signup action, and
   login action — SvelteKit never saw the redirect, so pages silently
   reloaded with no navigation. Also forward JSESSIONID from backend
   response to browser explicitly, since SvelteKit does not
   auto-forward Set-Cookie for cross-origin server-side fetches.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:31:29 +02:00
ab3363eeec refactor(auth): use shared BrandPanel on login page
Login page now uses the same BrandPanel component as signup
instead of an inline brand panel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:45:22 +02:00
999e54de86 feat(auth): build login page with LoginForm, brand panel, and title
Replaces placeholder with full login page: brand panel left,
LoginForm right, svelte:head title, signup link, no-nav-chrome.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:21:31 +02:00
73acc0c638 feat(auth): add login server action with validation and redirect
POSTs to /v1/auth/login, validates email/password server-side,
redirects to ?redirect param or /planner on success.
Returns generic error on bad credentials to prevent enumeration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:20:02 +02:00
c27c97ff7d feat(auth): add LoginForm component with validation and password toggle
Email/password fields, client-side validation, password show/hide,
server error display via form prop, signup link.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 16:18:49 +02:00
b3607ca47a test(auth): add password length boundary tests (7 fails, 8 passes)
Parameterized test verifying the exact boundary of the 8-character
minimum password requirement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:07:42 +02:00
7de18740f2 test(auth): add multi-error test for empty form submission
Verifies all three validation errors (name, email, password) appear
simultaneously when submitting a completely empty form.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:07:07 +02:00
6d0f00c8fb feat(auth): add use:enhance and server error display to signup form
SignupForm now uses use:enhance for progressive enhancement.
Accepts form prop for server-side error display. Shows general
form errors in a banner and field-specific errors inline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:06:21 +02:00
bd9e1334e0 feat(auth): add server-side validation to signup form action
Validates displayName, email, password server-side before calling
the backend API. Handles null from formData.get() safely.
Returns structured field errors via fail(400, { errors }).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:02:33 +02:00
82840bb420 fix(auth): center signup form on wide desktop screens
Form container now horizontally centered on md+ viewports,
left-aligned on mobile for full-width usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:01:03 +02:00
845e669cde feat(auth): add page title to signup screen
Sets <title>Konto erstellen — Mealprep</title> via svelte:head
for browser tab and accessibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:00:02 +02:00
afcea6590d feat(auth): add autocomplete attributes to signup form inputs
name, email, new-password for better browser/password-manager support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:58:59 +02:00
75a13d9df1 fix(auth): style login link green/font-medium per spec
Spec shows green text with font-weight 500, no underline by default.
Was dark text with underline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:57:47 +02:00
b71c98662b fix(auth): use --green-dark on submit button for WCAG AA contrast
--green (#3D8C4A) gives 4.16:1 against white — fails AA.
--green-dark (#2E6E39) passes comfortably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:56:49 +02:00
bfa8f20fe3 test(auth): add no-nav-chrome regression test for signup page
Verifies signup page renders form and brand panel but no
navigation elements (tabs, sidebar, links to app routes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:48:23 +02:00
596652d6e4 feat(auth): add signup page with form action
Composes BrandPanel + SignupForm in responsive split layout.
Server action POSTs to /v1/auth/signup and redirects to
/household/setup on success.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:47:36 +02:00
d3a8518298 feat(auth): add SignupForm component with validation and password toggle
Form with name/email/password fields, client-side validation,
inline error messages, and password show/hide toggle.
Uses native form action for progressive enhancement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:45:54 +02:00
d5d85d1156 rename backend service 2026-04-02 14:45:11 +02:00
e8fe69a543 feat(auth): add BrandPanel component for signup screen
Renders brand identity with logo, app name, tagline, and feature icons
on green-dark background. Responsive: banner on mobile, 440px column
on desktop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:41:10 +02:00
56fc7e6052 feat(auth): add /signup to public routes
Allow unauthenticated access to the signup page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:39:17 +02:00
66cf538454 refactor(auth): make (public) layout bare, move brand panel into login page
The signup page needs its own brand panel, so the shared layout
becomes a simple slot. Login page now owns its brand panel markup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 14:38:30 +02:00
682580e11d feat(nav): add hover state on inactive tablet and desktop nav items
Applies hover:bg-[var(--color-subtle)] to inactive nav links for
visual feedback on pointer devices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:04:50 +02:00
5c066d33ef feat(nav): add emoji icons to all nav components
Renders emoji icons in MobileTabBar (stacked above label),
TabletNavBar (inline), and DesktopSidebar (16px, 20px column).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:03:53 +02:00
4bd020fa16 test(nav): add parameterized active-state tests for all routes
Proves active state logic generalizes beyond /planner by testing
all 4 mobile nav routes with writable page store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:01:26 +02:00
bd8e901685 fix(nav): use segment-boundary route matching to prevent false positives
Extracts isActiveRoute() into shared nav module. Matches exact path
or path + '/' prefix, preventing /settings from matching /settings-advanced.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:00:18 +02:00
aeaca76534 fix(auth): handle users without household — fallback to 'Kein Haushalt'
Removes non-null assertions on householdId/householdName. Users who
haven't joined a household get a fallback name in the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:58:37 +02:00
32550377aa fix(auth): read JSESSIONID cookie to match Spring Security default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:57:34 +02:00
92c7d8f92e feat(auth): preserve redirect URL when redirecting to /login
Appends ?redirect= with the original pathname so the login page
can redirect back after successful authentication.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:56:49 +02:00
cc74c0042a test(auth): add isPublicRoute boundary tests for sub-paths and trailing slash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:48 +02:00
2bdb1010f8 fix(auth): bypass auth guard for static assets and favicon
Prevents redirect loop when backend is down — login page CSS/JS
would otherwise be redirected to /login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:55:03 +02:00
d7f317587e refactor(public): add lang="ts" to public layout for consistency
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:53:56 +02:00
05bf66de56 refactor(test): replace require() with import() in $app/stores mocks
CJS require() is fragile in an ESM project. Use async import() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:53:20 +02:00
db4b01ca77 refactor(config): document resolve.conditions safety for SSR builds
Verified: SvelteKit's plugin overrides resolve.conditions for SSR
builds. The global 'browser' condition only affects vitest and dev.
Build output confirmed correct with npm run build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:52:23 +02:00
9626bde694 feat(shell): add route groups, layout server load, redirect, and placeholder pages
- (app) group with AppShell layout, loads user/household from locals
- (public) group with full-viewport split layout, /login placeholder
- Root / redirects to /planner for authenticated users
- Placeholder stubs for planner, recipes, shopping, settings, members

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:22:34 +02:00
7a17873046 feat(auth): add auth guard in hooks.server.ts with session validation
Validates session cookie via GET /v1/auth/me, populates event.locals
with benutzer and haushalt, redirects to /login if unauthenticated.
Public routes (/login, /register, /invite) bypass auth.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:19:40 +02:00
cfe38c39aa feat(nav): add AppShell layout with breakpoint-switched navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:18:09 +02:00
56cfd137aa feat(nav): add DesktopSidebar with logo, nav sections, and variety widget slot
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:16:12 +02:00
8f33f469de feat(nav): add TabletNavBar with horizontal pills and active state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:14:12 +02:00
d3fa8991fe feat(nav): add MobileTabBar with active state and safe-area padding
Fixed vitest resolve conditions to use browser entry for Svelte 5.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:12:04 +02:00
7ae1f3dc18 feat(nav): add shared navigation config with mobile and desktop items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:09:26 +02:00
0a2ef750c4 feat(design-system): add Tailwind 4 @theme tokens, fonts, and completeness tests
- Load Fraunces, DM Sans, DM Mono via Google Fonts preconnect in app.html
- Define all design tokens in @theme block: neutrals, green/yellow/blue/
  purple/orange scales, spacing (--space-1..20), radii, shadows, button base
- Note --green-dark as button background (--green fails WCAG AA with white)
- Add @types/node for Node fs/path usage in design-system tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:45:11 +02:00
7c8d725fce test(design-system): assert WCAG 2.2 AA contrast for key color pairs
White on --green-dark (not --green) is the correct button background;
--green (#3D8C4A) gives only 4.16:1 which fails AA for normal-size text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:43:21 +02:00
82815205d0 Wire frontend into Docker Compose with type-safe API client
- Add frontend service to docker-compose.yml (port 3000, BACKEND_URL env var)
- Add frontend/Dockerfile using adapter-node for plain Node/Docker runtime
- Switch svelte.config.js from adapter-auto to adapter-node
- Generate OpenAPI types from backend spec (openapi-typescript + openapi-fetch)
- Add src/lib/server/api.ts as server-only typed API client factory
- Add generate:api script to regenerate types when backend spec changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 12:36:09 +02:00
b36d4c731d Add frontend journey specs with visual previews and LLM instructions
Six self-contained HTML documents, one per user journey, each combining
mobile/desktop previews with machine-readable implementation guides:

- j1-add-recipe.html (B1, B3)
- j2-plan-the-week.html (C1, C2, C3)
- j3-cook-tonight.html (B2, B4)
- j4-adapt-on-the-fly.html (swap trigger, C2 swap)
- j5-shopping-list.html (D1)
- j6-household-setup.html (A1, A2, A3/D3, A4)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 12:17:47 +02:00
83 changed files with 12831 additions and 11 deletions

View File

@@ -7,9 +7,15 @@ import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.*;
import java.security.Principal;
import java.util.List;
@RestController
@RequestMapping("/v1/auth")
@@ -26,7 +32,7 @@ public class AuthController {
@Valid @RequestBody SignupRequest request,
HttpServletRequest httpRequest) {
UserResponse user = authService.signup(request);
httpRequest.getSession(true);
authenticateInSession(user.email(), "user", httpRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user));
}
@@ -35,17 +41,37 @@ public class AuthController {
@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest) {
UserResponse user = authService.login(request);
HttpSession session = httpRequest.getSession(true);
session.setAttribute("user_email", user.email());
// Session fixation protection: invalidate old session before creating new one
var oldSession = httpRequest.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest);
return ResponseEntity.ok(ApiResponse.success(user));
}
/**
* Creates an authenticated Spring Security context and stores it in the HTTP session
* so that subsequent requests from the same session are recognised as authenticated.
* We do this manually because we are not using Spring Security's built-in form login.
*/
private void authenticateInSession(String email, String role, HttpServletRequest request) {
var auth = UsernamePasswordAuthenticationToken.authenticated(
email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
request.getSession(true).setAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context);
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest httpRequest) {
HttpSession session = httpRequest.getSession(false);
if (session != null) {
session.invalidate();
}
SecurityContextHolder.clearContext();
return ResponseEntity.noContent().build();
}

View File

@@ -10,8 +10,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
@Configuration
@EnableWebSecurity
@@ -20,9 +18,9 @@ public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
// CSRF is disabled: SvelteKit is the only client and submits form actions
// server-side, so the browser never calls the backend directly.
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()

View File

@@ -10,16 +10,20 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.UUID;
import static org.hamcrest.Matchers.notNullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@ExtendWith(MockitoExtension.class)
class AuthControllerTest {
@@ -95,6 +99,40 @@ class AuthControllerTest {
.andExpect(jsonPath("$.data.systemRole").value("user"));
}
@Test
void signupShouldStoreSecurityContextInSession() throws Exception {
var request = new SignupRequest("sarah@example.com", "s3cure!Pass", "Sarah");
var response = UserResponse.basic(UUID.randomUUID(), "sarah@example.com", "Sarah");
when(authService.signup(any(SignupRequest.class))).thenReturn(response);
mockMvc.perform(post("/v1/auth/signup")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(request().sessionAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
notNullValue()));
}
@Test
void loginShouldStoreSecurityContextInSession() throws Exception {
var request = new LoginRequest("sarah@example.com", "s3cure!Pass");
var response = UserResponse.withHousehold(
UUID.randomUUID(), "sarah@example.com", "Sarah",
UUID.randomUUID(), "Smith family", "planner", "user");
when(authService.login(any(LoginRequest.class))).thenReturn(response);
mockMvc.perform(post("/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(request().sessionAttribute(
HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
notNullValue()));
}
@Test
void logoutShouldReturn204() throws Exception {
mockMvc.perform(post("/v1/auth/logout"))

View File

@@ -16,11 +16,11 @@ services:
timeout: 3s
retries: 5
app:
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: mealprep-app
container_name: mealprep-backend
ports:
- "8080:8080"
environment:
@@ -32,5 +32,17 @@ services:
db:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: mealprep-frontend
ports:
- "3000:3000"
environment:
BACKEND_URL: http://backend:8080
depends_on:
- backend
volumes:
pgdata:

15
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/build build/
COPY --from=build /app/node_modules node_modules/
COPY package.json .
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "build"]

4064
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"generate:api": "curl -s http://localhost:8080/v3/api-docs -o src/lib/api/openapi.json && openapi-typescript src/lib/api/openapi.json -o src/lib/api/schema.d.ts"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.5.0",
"@vitest/ui": "^4.1.2",
"jsdom": "^29.0.1",
"openapi-typescript": "^7.13.0",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.2.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.2"
},
"dependencies": {
"openapi-fetch": "^0.17.0"
}
}

89
frontend/src/app.css Normal file
View File

@@ -0,0 +1,89 @@
@import 'tailwindcss';
@theme {
/* ── Fonts ─────────────────────────────────────────────────────── */
--font-display: 'Fraunces', Georgia, serif;
--font-sans: 'DM Sans', system-ui, sans-serif;
--font-mono: 'DM Mono', monospace;
/* ── Neutrals ───────────────────────────────────────────────────── */
--color-page: #fafaf7;
--color-surface: #f5f4ee;
--color-subtle: #edecea;
--color-border: #d8d7d0;
--color-text: #1c1c18;
--color-text-muted: #6b6a63;
/* ── Green scale ────────────────────────────────────────────────── */
--green-tint: #e8f5ea;
--green-light: #aedcb0;
--green: #3d8c4a;
--green-dark: #2e6e39; /* button backgrounds with white text — #3D8C4A gives 4.16:1, fails AA */
--green-deeper: #1e4a26;
/* ── Yellow scale ───────────────────────────────────────────────── */
--yellow-tint: #fdf6d8;
--yellow-light: #f9e08a;
--yellow: #f2c12e;
--yellow-dark: #c49610;
--yellow-text: #8a6800;
/* ── Blue scale ─────────────────────────────────────────────────── */
--blue-tint: #e6f1fb;
--blue-light: #a4cff4;
--blue: #2d7dd2;
--blue-dark: #185fa5;
/* ── Purple scale ───────────────────────────────────────────────── */
--purple-tint: #eeedfe;
--purple: #534ab7;
--purple-dark: #3c3489;
/* ── Orange scale ───────────────────────────────────────────────── */
--orange-tint: #fef0e6;
--orange: #e8862a;
--orange-dark: #b46820;
/* ── Status ─────────────────────────────────────────────────────── */
--color-error: #dc4c3e;
/* ── Spacing (8px base grid, 4px half-step) ─────────────────────── */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-7: 28px;
--space-8: 32px;
--space-9: 36px;
--space-10: 40px;
--space-11: 44px;
--space-12: 48px;
--space-13: 52px;
--space-14: 56px;
--space-15: 60px;
--space-16: 64px;
--space-17: 68px;
--space-18: 72px;
--space-19: 76px;
--space-20: 80px;
/* ── Radii ──────────────────────────────────────────────────────── */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px; /* default */
--radius-lg: 10px;
--radius-xl: 16px;
--radius-full: 9999px;
/* ── Elevation ──────────────────────────────────────────────────── */
--shadow-card: 0 1px 3px rgba(28, 28, 24, 0.06), 0 1px 2px rgba(28, 28, 24, 0.04);
--shadow-raised: 0 4px 12px rgba(28, 28, 24, 0.08), 0 2px 4px rgba(28, 28, 24, 0.04);
--shadow-overlay: 0 8px 32px rgba(28, 28, 24, 0.12), 0 2px 8px rgba(28, 28, 24, 0.06);
/* ── Button base tokens ─────────────────────────────────────────── */
--btn-font-size: 13px;
--btn-font-weight: 500;
--btn-letter-spacing: 0.04em;
}

25
frontend/src/app.d.ts vendored Normal file
View File

@@ -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 {};

15
frontend/src/app.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -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', '/signup', '/signup/', '/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');
}
});
});

View File

@@ -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', '/signup', '/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);
throw 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);
};

File diff suppressed because one or more lines are too long

1992
frontend/src/lib/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
<script lang="ts">
</script>
<div
class="bg-[var(--green-dark)] flex flex-col items-center justify-center
px-6 py-7 text-center
md:w-[440px] md:min-h-screen md:px-10 md:py-12"
>
<span class="text-[28px] md:text-[64px]" aria-hidden="true">🥗</span>
<h1
class="mt-2 font-[var(--font-display)] text-[22px] font-medium tracking-[-0.02em] text-white
md:mt-3 md:text-[36px]"
>
Mealprep
</h1>
<p class="mt-1 text-[12px] text-[var(--green-light)] md:mt-2 md:text-[15px]">
Plan meals, eat well, waste less
</p>
<div class="mt-8 hidden gap-3 md:flex">
{#each [{ emoji: '📅', label: 'Plan' }, { emoji: '🍳', label: 'Cook' }, { emoji: '🛒', label: 'Shop' }] as feature (feature.label)}
<div
class="flex flex-col items-center gap-1 rounded-lg bg-white/10 px-4 py-3"
>
<span class="text-[18px]">{feature.emoji}</span>
<span class="text-[10px] text-[var(--green-light)]">{feature.label}</span>
</div>
{/each}
</div>
</div>

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import BrandPanel from './BrandPanel.svelte';
describe('BrandPanel', () => {
it('renders the app name', () => {
render(BrandPanel);
expect(screen.getByText('Mealprep')).toBeInTheDocument();
});
it('renders the tagline', () => {
render(BrandPanel);
expect(screen.getByText('Plan meals, eat well, waste less')).toBeInTheDocument();
});
it('renders the logo emoji', () => {
render(BrandPanel);
expect(screen.getByText('🥗')).toBeInTheDocument();
});
it('renders three feature icons with labels', () => {
render(BrandPanel);
expect(screen.getByText('Plan')).toBeInTheDocument();
expect(screen.getByText('Cook')).toBeInTheDocument();
expect(screen.getByText('Shop')).toBeInTheDocument();
});
it('renders feature emojis', () => {
render(BrandPanel);
expect(screen.getByText('📅')).toBeInTheDocument();
expect(screen.getByText('🍳')).toBeInTheDocument();
expect(screen.getByText('🛒')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,154 @@
<script lang="ts">
import { enhance } from '$app/forms';
type FormResult = {
errors?: Record<string, string>;
email?: string;
} | null;
let { form = null }: { form?: FormResult } = $props();
let email = $state('');
let password = $state('');
let showPassword = $state(false);
let formError = $state('');
let errors = $state({
email: '',
password: ''
});
$effect(() => {
if (form?.errors) {
errors.email = form.errors.email ?? '';
errors.password = form.errors.password ?? '';
formError = form.errors.form ?? '';
if (form.email) email = form.email;
}
});
function validate(): boolean {
let hasError = false;
errors.email = '';
errors.password = '';
formError = '';
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
errors.email = 'Ungültige E-Mail-Adresse';
hasError = true;
}
if (!password) {
errors.password = 'Passwort ist erforderlich';
hasError = true;
}
return hasError;
}
function handleSubmit(event: SubmitEvent) {
if (validate()) {
event.preventDefault();
}
}
</script>
<form method="POST" novalidate use:enhance onsubmit={handleSubmit}>
<h1
class="font-[var(--font-display)] text-[18px] font-medium tracking-[-0.02em] text-[var(--color-text)]
md:text-[28px]"
>
Willkommen zurück
</h1>
<p
class="mb-[20px] text-[12px] text-[var(--color-text-muted)]
md:mb-[32px] md:text-[14px]"
>
Melde dich an, um fortzufahren.
</p>
<!-- Email field -->
<div class="mb-[16px]">
<label
for="email"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
E-Mail
</label>
<input
type="email"
id="email"
name="email"
placeholder="du@beispiel.de"
autocomplete="email"
bind:value={email}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.email ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
/>
{#if errors.email}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{errors.email}
</p>
{/if}
</div>
<!-- Password field -->
<div class="mb-[16px]">
<label
for="password"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Passwort
</label>
<div class="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
placeholder="Dein Passwort"
autocomplete="current-password"
bind:value={password}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] pr-[44px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.password ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
class="absolute top-1/2 right-[12px] -translate-y-1/2 cursor-pointer bg-transparent p-0 text-[12px] text-[var(--color-text-muted)]"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
{#if errors.password}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{errors.password}
</p>
{/if}
</div>
{#if formError}
<p class="mb-[16px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]">
{formError}
</p>
{/if}
<!-- Submit button -->
<button
type="submit"
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white"
>
Anmelden &rarr;
</button>
<!-- Signup link -->
<p
class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)]
md:text-[13px]"
>
Noch kein Konto? <a href="/signup" class="font-medium text-[var(--green)] hover:underline">Registrieren</a>
</p>
</form>

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import LoginForm from './LoginForm.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('LoginForm', () => {
it('renders email and password fields with correct labels', () => {
render(LoginForm);
expect(screen.getByLabelText('E-Mail')).toBeInTheDocument();
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
});
it('renders heading and subtitle', () => {
render(LoginForm);
expect(screen.getByText('Willkommen zurück')).toBeInTheDocument();
expect(screen.getByText('Melde dich an, um fortzufahren.')).toBeInTheDocument();
});
it('renders submit button', () => {
render(LoginForm);
expect(screen.getByRole('button', { name: /anmelden/i })).toBeInTheDocument();
});
it('renders signup link', () => {
render(LoginForm);
const link = screen.getByRole('link', { name: /registrieren/i });
expect(link).toHaveAttribute('href', '/signup');
expect(link.className).toContain('text-[var(--green)]');
expect(link.className).toContain('font-medium');
});
it('submit button uses --green-dark for WCAG AA', () => {
render(LoginForm);
const button = screen.getByRole('button', { name: /anmelden/i });
expect(button.className).toContain('bg-[var(--green-dark)]');
});
it('password field is initially of type password', () => {
render(LoginForm);
expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password');
});
it('password toggle switches type', async () => {
const user = userEvent.setup();
render(LoginForm);
const input = screen.getByLabelText('Passwort');
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
await user.click(toggle);
expect(input).toHaveAttribute('type', 'text');
await user.click(toggle);
expect(input).toHaveAttribute('type', 'password');
});
it('inputs have correct autocomplete attributes', () => {
render(LoginForm);
expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email');
expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'current-password');
});
it('shows validation error for invalid email on submit', async () => {
const user = userEvent.setup();
render(LoginForm);
await user.type(screen.getByLabelText('E-Mail'), 'notanemail');
await user.type(screen.getByLabelText('Passwort'), 'password123');
await user.click(screen.getByRole('button', { name: /anmelden/i }));
expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
});
it('shows validation error for empty password on submit', async () => {
const user = userEvent.setup();
render(LoginForm);
await user.type(screen.getByLabelText('E-Mail'), 'test@example.com');
await user.click(screen.getByRole('button', { name: /anmelden/i }));
expect(screen.getByText('Passwort ist erforderlich')).toBeInTheDocument();
});
it('shows no errors when fields are valid', async () => {
const user = userEvent.setup();
render(LoginForm);
await user.type(screen.getByLabelText('E-Mail'), 'test@example.com');
await user.type(screen.getByLabelText('Passwort'), 'password123');
await user.click(screen.getByRole('button', { name: /anmelden/i }));
expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument();
expect(screen.queryByText('Passwort ist erforderlich')).not.toBeInTheDocument();
});
it('displays server-side form error from form prop', () => {
render(LoginForm, {
props: {
form: {
errors: { form: 'E-Mail oder Passwort ist falsch.' },
email: 'test@example.com'
}
}
});
expect(screen.getByText('E-Mail oder Passwort ist falsch.')).toBeInTheDocument();
});
it('renders placeholders', () => {
render(LoginForm);
expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Dein Passwort')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { enhance } from '$app/forms';
type FormResult = {
errors?: Record<string, string>;
displayName?: string;
email?: string;
} | null;
let { form = null }: { form?: FormResult } = $props();
let displayName = $state('');
let email = $state('');
let password = $state('');
let showPassword = $state(false);
let formError = $state('');
let errors = $state({
displayName: '',
email: '',
password: ''
});
$effect(() => {
if (form?.errors) {
errors.displayName = form.errors.displayName ?? '';
errors.email = form.errors.email ?? '';
errors.password = form.errors.password ?? '';
formError = form.errors.form ?? '';
if (form.displayName) displayName = form.displayName;
if (form.email) email = form.email;
}
});
function validate(): boolean {
let hasError = false;
errors.displayName = '';
errors.email = '';
errors.password = '';
formError = '';
if (!displayName.trim()) {
errors.displayName = 'Name ist erforderlich';
hasError = true;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
errors.email = 'Ungültige E-Mail-Adresse';
hasError = true;
}
if (password.length < 8) {
errors.password = 'Mindestens 8 Zeichen';
hasError = true;
}
return hasError;
}
function handleSubmit(event: SubmitEvent) {
if (validate()) {
event.preventDefault();
}
}
</script>
<form method="POST" novalidate use:enhance onsubmit={handleSubmit}>
<h1
class="font-[var(--font-display)] text-[18px] font-medium tracking-[-0.02em] text-[var(--color-text)]
md:text-[28px]"
>
Konto erstellen
</h1>
<p
class="mb-[20px] text-[12px] text-[var(--color-text-muted)]
md:mb-[32px] md:text-[14px]"
>
Danach richtest du deinen Haushalt ein.
</p>
<!-- Name field -->
<div class="mb-[16px]">
<label
for="displayName"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Dein Name
</label>
<input
type="text"
id="displayName"
name="displayName"
placeholder="z.B. Sarah"
autocomplete="name"
bind:value={displayName}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.displayName ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
/>
{#if errors.displayName}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{errors.displayName}
</p>
{/if}
</div>
<!-- Email field -->
<div class="mb-[16px]">
<label
for="email"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
E-Mail
</label>
<input
type="email"
id="email"
name="email"
placeholder="du@beispiel.de"
autocomplete="email"
bind:value={email}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.email ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
/>
{#if errors.email}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{errors.email}
</p>
{/if}
</div>
<!-- Password field -->
<div class="mb-[16px]">
<label
for="password"
class="mb-[6px] block text-[12px] font-medium text-[var(--color-text)]"
>
Passwort
</label>
<div class="relative">
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
placeholder="Mindestens 8 Zeichen"
autocomplete="new-password"
bind:value={password}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] pr-[44px] font-[var(--font-sans)] text-[14px] text-[var(--color-text)] outline-none
{errors.password ? 'border-[var(--color-error)]' : 'border-[var(--color-border)]'}"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
class="absolute top-1/2 right-[12px] -translate-y-1/2 cursor-pointer bg-transparent p-0 text-[12px] text-[var(--color-text-muted)]"
>
{showPassword ? 'Verbergen' : 'Anzeigen'}
</button>
</div>
{#if errors.password}
<p class="mt-1 font-[var(--font-sans)] text-[12px] text-[var(--color-error)]">
{errors.password}
</p>
{/if}
</div>
{#if formError}
<p class="mb-[16px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]">
{formError}
</p>
{/if}
<!-- Submit button -->
<button
type="submit"
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[var(--btn-font-size)] font-[var(--btn-font-weight)] tracking-[var(--btn-letter-spacing)] text-white"
>
Konto erstellen &rarr;
</button>
<!-- Login link -->
<p
class="mt-[16px] text-center text-[12px] text-[var(--color-text-muted)]
md:text-[13px]"
>
Du hast bereits ein Konto? <a href="/login" class="font-medium text-[var(--green)] hover:underline">Anmelden</a>
</p>
</form>

View File

@@ -0,0 +1,203 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import SignupForm from './SignupForm.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('SignupForm', () => {
it('renders all form fields with correct labels', () => {
render(SignupForm);
expect(screen.getByLabelText('Dein Name')).toBeInTheDocument();
expect(screen.getByLabelText('E-Mail')).toBeInTheDocument();
expect(screen.getByLabelText('Passwort')).toBeInTheDocument();
});
it('renders submit button with correct text', () => {
render(SignupForm);
expect(screen.getByRole('button', { name: /konto erstellen/i })).toBeInTheDocument();
});
it('renders login link with correct href and styling', () => {
render(SignupForm);
const link = screen.getByRole('link', { name: /anmelden/i });
expect(link).toHaveAttribute('href', '/login');
expect(link.className).toContain('text-[var(--green)]');
expect(link.className).toContain('font-medium');
});
it('renders heading and subtitle', () => {
render(SignupForm);
expect(screen.getByText('Konto erstellen')).toBeInTheDocument();
expect(screen.getByText('Danach richtest du deinen Haushalt ein.')).toBeInTheDocument();
});
it('password field is initially of type password', () => {
render(SignupForm);
const input = screen.getByLabelText('Passwort');
expect(input).toHaveAttribute('type', 'password');
});
it('password toggle switches to text and back', async () => {
const user = userEvent.setup();
render(SignupForm);
const input = screen.getByLabelText('Passwort');
const toggle = screen.getByRole('button', { name: /passwort anzeigen/i });
await user.click(toggle);
expect(input).toHaveAttribute('type', 'text');
await user.click(toggle);
expect(input).toHaveAttribute('type', 'password');
});
it('shows validation error for empty name on submit', async () => {
const user = userEvent.setup();
render(SignupForm);
const email = screen.getByLabelText('E-Mail');
const password = screen.getByLabelText('Passwort');
await user.type(email, 'test@example.com');
await user.type(password, 'password123');
const submit = screen.getByRole('button', { name: /konto erstellen/i });
await user.click(submit);
expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument();
});
it('shows validation error for invalid email on submit', async () => {
const user = userEvent.setup();
render(SignupForm);
const name = screen.getByLabelText('Dein Name');
const email = screen.getByLabelText('E-Mail');
const password = screen.getByLabelText('Passwort');
await user.type(name, 'Sarah');
await user.type(email, 'notanemail');
await user.type(password, 'password123');
const submit = screen.getByRole('button', { name: /konto erstellen/i });
await user.click(submit);
expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
});
it('shows validation error for short password on submit', async () => {
const user = userEvent.setup();
render(SignupForm);
const name = screen.getByLabelText('Dein Name');
const email = screen.getByLabelText('E-Mail');
const password = screen.getByLabelText('Passwort');
await user.type(name, 'Sarah');
await user.type(email, 'test@example.com');
await user.type(password, 'short');
const submit = screen.getByRole('button', { name: /konto erstellen/i });
await user.click(submit);
expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
});
it('shows no validation errors when all fields are valid', async () => {
const user = userEvent.setup();
render(SignupForm);
const name = screen.getByLabelText('Dein Name');
const email = screen.getByLabelText('E-Mail');
const password = screen.getByLabelText('Passwort');
await user.type(name, 'Sarah');
await user.type(email, 'test@example.com');
await user.type(password, 'password123');
const submit = screen.getByRole('button', { name: /konto erstellen/i });
await user.click(submit);
expect(screen.queryByText('Name ist erforderlich')).not.toBeInTheDocument();
expect(screen.queryByText('Ungültige E-Mail-Adresse')).not.toBeInTheDocument();
expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument();
});
it('submit button uses --green-dark for WCAG AA contrast', () => {
render(SignupForm);
const button = screen.getByRole('button', { name: /konto erstellen/i });
expect(button.className).toContain('bg-[var(--green-dark)]');
});
it('inputs have correct autocomplete attributes', () => {
render(SignupForm);
expect(screen.getByLabelText('Dein Name')).toHaveAttribute('autocomplete', 'name');
expect(screen.getByLabelText('E-Mail')).toHaveAttribute('autocomplete', 'email');
expect(screen.getByLabelText('Passwort')).toHaveAttribute('autocomplete', 'new-password');
});
it('displays server-side form error when form prop has errors', () => {
render(SignupForm, {
props: {
form: {
errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' },
displayName: 'Sarah',
email: 'sarah@example.com'
}
}
});
expect(
screen.getByText('Registrierung fehlgeschlagen. Bitte versuche es erneut.')
).toBeInTheDocument();
});
it('displays server-side field errors from form prop', () => {
render(SignupForm, {
props: {
form: {
errors: { email: 'Ungültige E-Mail-Adresse' },
displayName: 'Sarah',
email: 'bad'
}
}
});
expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
});
it('shows all three validation errors when form submitted empty', async () => {
const user = userEvent.setup();
render(SignupForm);
const submit = screen.getByRole('button', { name: /konto erstellen/i });
await user.click(submit);
expect(screen.getByText('Name ist erforderlich')).toBeInTheDocument();
expect(screen.getByText('Ungültige E-Mail-Adresse')).toBeInTheDocument();
expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
});
it.each([
{ length: 7, shouldFail: true },
{ length: 8, shouldFail: false }
])('password with $length chars $shouldFail ? fails : passes validation', async ({ length, shouldFail }) => {
const user = userEvent.setup();
render(SignupForm);
await user.type(screen.getByLabelText('Dein Name'), 'Sarah');
await user.type(screen.getByLabelText('E-Mail'), 'test@example.com');
await user.type(screen.getByLabelText('Passwort'), 'a'.repeat(length));
await user.click(screen.getByRole('button', { name: /konto erstellen/i }));
if (shouldFail) {
expect(screen.getByText('Mindestens 8 Zeichen')).toBeInTheDocument();
} else {
expect(screen.queryByText('Mindestens 8 Zeichen')).not.toBeInTheDocument();
}
});
it('renders placeholders on inputs', () => {
render(SignupForm);
expect(screen.getByPlaceholderText('z.B. Sarah')).toBeInTheDocument();
expect(screen.getByPlaceholderText('du@beispiel.de')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Mindestens 8 Zeichen')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
<script lang="ts">
const { currentStep }: { currentStep: number } = $props();
const steps = [
{
number: 1,
label: 'Haushalt benennen',
subtitle: 'Deiner Familie einen Namen geben'
},
{
number: 2,
label: 'Vorräte einrichten',
subtitle: 'Was ihr immer zu Hause habt'
},
{
number: 3,
label: 'Mitglieder einladen',
subtitle: 'Haushalt teilen'
}
];
function circleClass(n: number): string {
if (n === currentStep) return 'bg-[var(--green)] text-white';
if (n < currentStep) return 'bg-[var(--green-tint)] text-[var(--green-dark)]';
return 'bg-[var(--color-subtle)] text-[var(--color-text-muted)]';
}
function labelClass(n: number): string {
if (n === currentStep) return 'text-[13px] font-medium text-[var(--color-text)]';
return 'text-[13px] text-[var(--color-text-muted)]';
}
</script>
<nav>
<!-- Logo row -->
<div class="flex items-center gap-[8px] mb-[40px]">
<div
class="w-[28px] h-[28px] rounded-[6px] bg-[var(--green)] flex items-center justify-center text-[14px]"
>
🥗
</div>
<span class="font-[var(--font-display)] text-[16px] font-medium">Mealplan</span>
</div>
<!-- Steps -->
<div class="flex flex-col gap-[24px]">
{#each steps as step (step.number)}
<div
class="flex gap-[12px] items-start"
data-testid="step-{step.number}"
data-state={step.number < currentStep
? 'completed'
: step.number === currentStep
? 'current'
: 'future'}
aria-current={step.number === currentStep ? 'step' : undefined}
>
<div
class="w-[28px] h-[28px] rounded-full flex items-center justify-center text-[12px] font-medium flex-shrink-0 {circleClass(step.number)}"
aria-label="Schritt {step.number}"
>
{step.number < currentStep ? '✓' : step.number}
</div>
<div>
<div class={labelClass(step.number)}>{step.label}</div>
<div class="text-[11px] text-[var(--color-text-muted)] mt-[2px]">{step.subtitle}</div>
</div>
</div>
{/each}
</div>
</nav>

View File

@@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ProgressSidebar from './ProgressSidebar.svelte';
describe('ProgressSidebar', () => {
it('renders the app logo and name', () => {
render(ProgressSidebar, { props: { currentStep: 1 } });
expect(screen.getByText('Mealplan')).toBeInTheDocument();
});
it('renders all 3 step labels', () => {
render(ProgressSidebar, { props: { currentStep: 1 } });
expect(screen.getByText('Haushalt benennen')).toBeInTheDocument();
expect(screen.getByText('Vorräte einrichten')).toBeInTheDocument();
expect(screen.getByText('Mitglieder einladen')).toBeInTheDocument();
});
it('step 1 active: renders green circle for step 1', () => {
render(ProgressSidebar, { props: { currentStep: 1 } });
const step1 = screen.getByTestId('step-1');
expect(step1).toHaveAttribute('aria-current', 'step');
});
it('step 1 active: steps 2 and 3 are not current', () => {
render(ProgressSidebar, { props: { currentStep: 1 } });
expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current');
});
it('step 2 active: step 1 is completed (checkmark), step 2 is current, step 3 is future', () => {
render(ProgressSidebar, { props: { currentStep: 2 } });
expect(screen.getByTestId('step-1')).toHaveAttribute('data-state', 'completed');
expect(screen.getByTestId('step-2')).toHaveAttribute('aria-current', 'step');
expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current');
});
it('step 1 completed has accessible label', () => {
render(ProgressSidebar, { props: { currentStep: 2 } });
const step1 = screen.getByTestId('step-1');
expect(step1).toHaveAttribute('data-state', 'completed');
expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument();
});
it('each step has an accessible aria-label', () => {
render(ProgressSidebar, { props: { currentStep: 1 } });
expect(screen.getByLabelText(/schritt 1/i)).toBeInTheDocument();
expect(screen.getByLabelText(/schritt 2/i)).toBeInTheDocument();
expect(screen.getByLabelText(/schritt 3/i)).toBeInTheDocument();
});
it('future steps do not have aria-current', () => {
render(ProgressSidebar, { props: { currentStep: 1 } });
expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current');
});
it('step 3 active: steps 1 and 2 are both completed', () => {
render(ProgressSidebar, { props: { currentStep: 3 } });
expect(screen.getByTestId('step-1')).toHaveAttribute('data-state', 'completed');
expect(screen.getByTestId('step-2')).toHaveAttribute('data-state', 'completed');
expect(screen.getByTestId('step-3')).toHaveAttribute('aria-current', 'step');
});
});

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
// WCAG 2.2 relative luminance for a single 8-bit channel value
function channelLuminance(val: number): number {
const sRGB = val / 255;
return sRGB <= 0.04045 ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4;
}
function relativeLuminance(hex: string): number {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
}
function contrastRatio(hex1: string, hex2: string): number {
const l1 = relativeLuminance(hex1);
const l2 = relativeLuminance(hex2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Design token values from app.css @theme
const tokens = {
colorText: '#1C1C18',
colorTextMuted: '#6B6A63',
colorPage: '#FAFAF7',
colorSurface: '#F5F4EE',
greenDark: '#2E6E39', // button background — --green (#3D8C4A) only gives 4.16:1, fails AA
white: '#FFFFFF'
};
describe('WCAG 2.2 AA contrast ratios', () => {
it('--color-text on --color-page meets 4.5:1 (normal text)', () => {
expect(contrastRatio(tokens.colorText, tokens.colorPage)).toBeGreaterThanOrEqual(4.5);
});
it('--color-text on --color-surface meets 4.5:1 (card text)', () => {
expect(contrastRatio(tokens.colorText, tokens.colorSurface)).toBeGreaterThanOrEqual(4.5);
});
it('--color-text-muted on --color-page meets 4.5:1 (muted labels)', () => {
expect(contrastRatio(tokens.colorTextMuted, tokens.colorPage)).toBeGreaterThanOrEqual(4.5);
});
it('white on --green-dark meets 4.5:1 (primary button background)', () => {
// --green (#3D8C4A) only gives 4.16:1 — buttons use --green-dark (#2E6E39) instead
expect(contrastRatio(tokens.white, tokens.greenDark)).toBeGreaterThanOrEqual(4.5);
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const css = readFileSync(resolve(__dirname, '../../app.css'), 'utf-8');
const requiredTokens = [
// Fonts
'--font-display',
'--font-sans',
'--font-mono',
// Neutrals
'--color-page',
'--color-surface',
'--color-subtle',
'--color-border',
'--color-text',
'--color-text-muted',
// Green scale
'--green-tint',
'--green-light',
'--green',
'--green-dark',
'--green-deeper',
// Yellow scale
'--yellow-tint',
'--yellow-light',
'--yellow',
'--yellow-dark',
'--yellow-text',
// Status
'--color-error',
// Spacing
'--space-1',
'--space-4',
'--space-8',
'--space-12',
'--space-16',
'--space-20',
// Radii
'--radius-xs',
'--radius-sm',
'--radius-md',
'--radius-lg',
'--radius-xl',
'--radius-full',
// Shadows
'--shadow-card',
'--shadow-raised',
'--shadow-overlay'
];
describe('design token completeness', () => {
it.each(requiredTokens)('%s is defined in app.css', (token) => {
expect(css).toContain(token);
});
});

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import MobileTabBar from './MobileTabBar.svelte';
import TabletNavBar from './TabletNavBar.svelte';
import DesktopSidebar from './DesktopSidebar.svelte';
let { appName, householdName, children }: { appName: string; householdName: string; children?: Snippet } = $props();
</script>
<div class="flex min-h-screen bg-[var(--color-page)]">
<DesktopSidebar {appName} {householdName} />
<div class="flex flex-1 flex-col">
<TabletNavBar />
<main class="flex-1">
{@render children?.()}
</main>
<MobileTabBar />
</div>
</div>

View File

@@ -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);
});
});

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { page } from '$app/stores';
import { desktopNavSections, isActiveRoute } from './nav';
let { appName, householdName }: { appName: string; householdName: string } = $props();
</script>
<aside
class="hidden lg:flex flex-col sticky top-0 h-screen w-[224px] min-w-[224px] border-r border-[var(--color-border)] bg-white"
>
<div class="px-[18px] pt-[14px] pb-[14px] border-b border-[var(--color-border)]">
<div class="flex items-center gap-2">
<div class="w-[22px] h-[22px] bg-[var(--green)] rounded-[var(--radius-sm)]"></div>
<span class="font-[var(--font-display)] text-[15px] font-medium">{appName}</span>
</div>
<p class="text-[10px] text-[var(--color-text-muted)]">{householdName}</p>
</div>
<nav aria-label="Hauptnavigation" class="flex-1 overflow-y-auto px-2 py-1">
{#each desktopNavSections as section (section.title)}
<p
class="text-[8px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-muted)] font-[var(--font-sans)] px-3 pt-4 pb-1"
>
{section.title}
</p>
{#each section.items as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="px-3 py-[7px] text-[13px] font-[var(--font-sans)] rounded-[var(--radius-md)] flex items-center gap-2 {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: 'hover:bg-[var(--color-subtle)]'}"
>
<span class="w-[20px] text-[16px] text-center">{item.icon}</span>
{item.label}
</a>
{/each}
{/each}
</nav>
<div data-testid="variety-widget-slot" class="mt-auto p-3"></div>
</aside>

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { page } from '$app/stores';
import { mobileNavItems, isActiveRoute } from './nav';
</script>
<nav
aria-label="Hauptnavigation"
class="fixed bottom-0 w-full flex justify-around bg-white border-t pb-[env(safe-area-inset-bottom,20px)] md:hidden"
>
{#each mobileNavItems as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="flex flex-col items-center gap-1 py-2 px-3 rounded-[var(--radius-md)] text-[10px] font-[var(--font-sans)] min-h-[44px] min-w-[44px] {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: ''}"
>
<span class="text-[16px]">{item.icon}</span>
{item.label}
</a>
{/each}
</nav>

View File

@@ -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');
});
});

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { page } from '$app/stores';
import { mobileNavItems, isActiveRoute } from './nav';
</script>
<nav
aria-label="Hauptnavigation"
class="hidden md:flex lg:hidden gap-2 items-center p-2"
>
{#each mobileNavItems as item (item.href)}
{@const active = isActiveRoute(item.href, $page.url.pathname)}
<a
href={item.href}
aria-current={active ? 'page' : undefined}
class="px-4 py-2 rounded-[var(--radius-md)] text-[13px] font-[var(--font-sans)] {active
? 'bg-[var(--green-tint)] text-[var(--green-dark)] font-medium'
: 'hover:bg-[var(--color-subtle)]'}"
>
<span>{item.icon}</span>
{item.label}
</a>
{/each}
</nav>

View File

@@ -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();
});
});

View File

@@ -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);
});
});
});

View File

@@ -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: '⚙️' }
]
}
];

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import StapleChip from './StapleChip.svelte';
type Ingredient = { id: string; name: string; isStaple: boolean };
let { name, ingredients, onToggle }: {
name: string;
ingredients: Ingredient[];
onToggle: (id: string, value: boolean) => void;
} = $props();
</script>
<div>
<p class="text-[10px] font-medium tracking-[.08em] uppercase text-[var(--color-text-muted)] mb-[8px]">
{name}
</p>
<div class="flex flex-wrap gap-[6px]">
{#each ingredients as ingredient (ingredient.id)}
<StapleChip
name={ingredient.name}
selected={ingredient.isStaple}
onToggle={(value) => onToggle(ingredient.id, value)}
/>
{/each}
</div>
</div>

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import CategorySection from './CategorySection.svelte';
const mockIngredients = [
{ id: '1', name: 'Olivenöl', isStaple: true },
{ id: '2', name: 'Butter', isStaple: false },
{ id: '3', name: 'Kokosöl', isStaple: false }
];
describe('CategorySection', () => {
it('renders the category name as a heading', () => {
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
});
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
});
it('renders a chip for each ingredient', () => {
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
});
expect(screen.getByRole('button', { name: 'Olivenöl' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Butter' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Kokosöl' })).toBeInTheDocument();
});
it('reflects isStaple state on each chip', () => {
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle: vi.fn() }
});
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false');
});
it('calls onToggle with ingredient id and new value when chip is clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(CategorySection, {
props: { name: 'Öle & Fette', ingredients: mockIngredients, onToggle }
});
await user.click(screen.getByRole('button', { name: 'Butter' }));
expect(onToggle).toHaveBeenCalledWith('2', true);
});
it('renders an empty category without crashing', () => {
render(CategorySection, {
props: { name: 'Leer', ingredients: [], onToggle: vi.fn() }
});
expect(screen.getByText('Leer')).toBeInTheDocument();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import { enhance } from '$app/forms';
type FormResult = {
errors?: Record<string, string>;
name?: string;
} | null;
let { form = null }: { form?: FormResult } = $props();
let name = $state('');
let touched = $state(false);
let submitAttempted = $state(false);
let formError = $state('');
const isDisabled = $derived(name.trim().length === 0);
const error = $derived(
(touched || submitAttempted) && name.trim() === '' ? 'Haushaltsname ist erforderlich' : ''
);
$effect(() => {
if (form?.errors) {
formError = form.errors.form ?? '';
name = form?.name ?? '';
}
});
function handleSubmit(event: SubmitEvent) {
submitAttempted = true;
if (name.trim() === '') {
event.preventDefault();
}
}
function handleInput() {
touched = true;
}
</script>
<form method="POST" novalidate use:enhance onsubmit={handleSubmit}>
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px]">Haushalt benennen</h1>
<p class="mb-[24px] text-[12px] text-[var(--color-text-muted)] md:text-[14px]">
Gib deinem Haushalt einen Namen, damit du ihn leicht wiederfindest.
</p>
<div class="mb-[16px]">
<label for="name" class="mb-[6px] block text-[14px] font-medium">Haushaltsname</label>
<input
type="text"
id="name"
name="name"
placeholder="z.B. Familie Müller"
autocomplete="organization"
bind:value={name}
oninput={handleInput}
class="w-full rounded-[var(--radius-md)] border bg-[var(--color-page)] px-[12px] py-[10px] text-[14px] outline-none focus:ring-2 focus:ring-[var(--green-dark)] {error
? 'border-[var(--color-error)]'
: 'border-[var(--color-border)]'}"
/>
{#if error}
<p class="mt-1 text-[12px] text-[var(--color-error)]">{error}</p>
{/if}
</div>
{#if formError}
<p
class="mb-[16px] rounded-[var(--radius-md)] bg-[color-mix(in_srgb,var(--color-error),transparent_90%)] px-[12px] py-[10px] text-[12px] text-[var(--color-error)]"
>
{formError}
</p>
{/if}
<button
type="submit"
disabled={isDisabled}
class="w-full cursor-pointer rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-[14px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
Weiter → Vorräte einrichten
</button>
</form>

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import HouseholdSetupForm from './HouseholdSetupForm.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('HouseholdSetupForm', () => {
it('renders household name input with label', () => {
render(HouseholdSetupForm);
expect(screen.getByLabelText('Haushaltsname')).toBeInTheDocument();
});
it('renders heading', () => {
render(HouseholdSetupForm);
expect(screen.getByText('Haushalt benennen')).toBeInTheDocument();
});
it('renders Continue button', () => {
render(HouseholdSetupForm);
expect(screen.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
});
it('Continue button is disabled when name is empty', () => {
render(HouseholdSetupForm);
const btn = screen.getByRole('button', { name: /weiter/i });
expect(btn).toBeDisabled();
});
it('Continue button is enabled when name has text', async () => {
const user = userEvent.setup();
render(HouseholdSetupForm);
await user.type(screen.getByLabelText('Haushaltsname'), 'Familie Müller');
expect(screen.getByRole('button', { name: /weiter/i })).not.toBeDisabled();
});
it('shows validation error when submitting with empty name', async () => {
const user = userEvent.setup();
render(HouseholdSetupForm);
// Type then clear: sets touched=true, which makes the $derived error visible
// as soon as the field is empty. The button is disabled so the click is a no-op,
// but the error is already shown from the touched+empty state.
const input = screen.getByLabelText('Haushaltsname');
await user.type(input, 'a');
await user.clear(input);
await user.click(screen.getByRole('button', { name: /weiter/i }));
expect(screen.getByText('Haushaltsname ist erforderlich')).toBeInTheDocument();
});
it('shows server-side error from form prop', () => {
render(HouseholdSetupForm, {
props: {
form: {
errors: { form: 'Haushalt konnte nicht erstellt werden.' },
name: 'Smith family'
}
}
});
expect(screen.getByText('Haushalt konnte nicht erstellt werden.')).toBeInTheDocument();
});
it('repopulates name from form prop on server error', () => {
render(HouseholdSetupForm, {
props: {
form: {
errors: { form: 'Fehler' },
name: 'Familie Müller'
}
}
});
expect(screen.getByLabelText('Haushaltsname')).toHaveValue('Familie Müller');
});
it('input has correct placeholder', () => {
render(HouseholdSetupForm);
expect(screen.getByPlaceholderText('z.B. Familie Müller')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,20 @@
<script lang="ts">
let { name, selected, onToggle }: {
name: string;
selected: boolean;
onToggle: (value: boolean) => void;
} = $props();
</script>
<button
type="button"
aria-pressed={selected}
onclick={() => onToggle(!selected)}
class="inline-flex font-sans text-[13px] font-medium tracking-[0.04em] px-[12px] py-[6px] rounded-full border cursor-pointer
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--green-light)]
{selected
? 'bg-[var(--green-tint)] border-[var(--green-light)] text-[var(--green-dark)]'
: 'bg-[var(--color-surface)] border-[var(--color-border)] text-[var(--color-text-muted)]'}"
>
{name}
</button>

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import StapleChip from './StapleChip.svelte';
describe('StapleChip', () => {
it('renders a button with the ingredient name', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toBeInTheDocument();
});
it('is aria-pressed="false" when unselected', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'false');
});
it('is aria-pressed="true" when selected', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: true, onToggle: vi.fn() } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
});
it('calls onToggle with true when unselected chip is clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle } });
await user.click(screen.getByRole('button', { name: 'Olivenöl' }));
expect(onToggle).toHaveBeenCalledWith(true);
});
it('calls onToggle with false when selected chip is clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(StapleChip, { props: { name: 'Olivenöl', selected: true, onToggle } });
await user.click(screen.getByRole('button', { name: 'Olivenöl' }));
expect(onToggle).toHaveBeenCalledWith(false);
});
it('has a visible focus ring class for keyboard accessibility', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
const btn = screen.getByRole('button', { name: 'Olivenöl' });
expect(btn.className).toContain('focus-visible:outline');
});
it('uses design-system button text spec: 13px, tracking, font-sans', () => {
render(StapleChip, { props: { name: 'Olivenöl', selected: false, onToggle: vi.fn() } });
const btn = screen.getByRole('button', { name: 'Olivenöl' });
expect(btn.className).toContain('text-[13px]');
expect(btn.className).toContain('tracking-[0.04em]');
expect(btn.className).toContain('font-sans');
});
});

View File

@@ -0,0 +1,80 @@
<script lang="ts">
import CategorySection from './CategorySection.svelte';
type Ingredient = { id: string; name: string; isStaple: boolean };
type Category = { id: string; name: string; ingredients: Ingredient[] };
let { categories, context }: {
categories: Category[];
context: 'onboarding' | 'settings';
} = $props();
let stapleState = $state<Record<string, boolean>>({});
let errorMessage = $state('');
$effect(() => {
const initial: Record<string, boolean> = {};
for (const cat of categories) {
for (const ing of cat.ingredients) {
initial[ing.id] = ing.isStaple;
}
}
stapleState = initial;
});
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let timer: ReturnType<typeof setTimeout> | null = null;
return ((...args: any[]) => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
}) as T;
}
const debouncedPatchers: Record<string, (id: string, value: boolean) => void> = {};
function getPatcher(id: string) {
if (!debouncedPatchers[id]) {
debouncedPatchers[id] = debounce(async (ingredientId: string, value: boolean) => {
const previous = !value;
const res = await fetch(`/household/staples`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: ingredientId, isStaple: value })
});
if (!res.ok) {
stapleState[ingredientId] = previous;
errorMessage = 'Vorrat konnte nicht gespeichert werden.';
}
}, 300);
}
return debouncedPatchers[id];
}
function handleToggle(ingredientId: string, newValue: boolean) {
errorMessage = '';
stapleState[ingredientId] = newValue;
getPatcher(ingredientId)(ingredientId, newValue);
}
</script>
<div>
{#if errorMessage}
<p class="mb-[12px] text-[12px] text-[var(--color-error)]">{errorMessage}</p>
{/if}
<div
data-testid="category-grid"
class="grid grid-cols-1 gap-[24px_32px] {context === 'settings' ? 'md:grid-cols-3' : 'md:grid-cols-2'}"
>
{#each categories as category (category.id)}
<CategorySection
name={category.name}
ingredients={category.ingredients.map(ing => ({
...ing,
isStaple: stapleState[ing.id] ?? ing.isStaple
}))}
onToggle={handleToggle}
/>
{/each}
</div>
</div>

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { userEvent } from '@testing-library/user-event';
import StaplesManager from './StaplesManager.svelte';
const mockCategories = [
{
id: 'cat-1',
name: 'Öle & Fette',
ingredients: [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
{ id: 'ing-2', name: 'Butter', isStaple: false }
]
},
{
id: 'cat-2',
name: 'Gewürze',
ingredients: [
{ id: 'ing-3', name: 'Salz', isStaple: true },
{ id: 'ing-4', name: 'Pfeffer', isStaple: true }
]
}
];
describe('StaplesManager', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it('renders all categories', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
expect(screen.getByText('Gewürze')).toBeInTheDocument();
});
it('renders all chips with correct initial aria-pressed state', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: 'Salz' })).toHaveAttribute('aria-pressed', 'true');
});
it('clicking a chip immediately updates aria-pressed (optimistic)', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const butter = screen.getByRole('button', { name: 'Butter' });
expect(butter).toHaveAttribute('aria-pressed', 'false');
await user.click(butter);
expect(butter).toHaveAttribute('aria-pressed', 'true');
});
it('rapid clicks on same chip result in exactly one fetch call after debounce', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const butter = screen.getByRole('button', { name: 'Butter' });
await user.click(butter);
await user.click(butter);
await user.click(butter);
expect(fetch).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('reverts chip and shows error when PATCH fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const butter = screen.getByRole('button', { name: 'Butter' });
await user.click(butter);
expect(butter).toHaveAttribute('aria-pressed', 'true');
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
expect(butter).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByText(/konnte nicht gespeichert werden/i)).toBeInTheDocument();
});
it('uses 2-column grid class in onboarding context', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
const grid = screen.getByTestId('category-grid');
expect(grid.className).toContain('md:grid-cols-2');
});
it('uses 3-column grid class in settings context', () => {
render(StaplesManager, { props: { categories: mockCategories, context: 'settings' } });
const grid = screen.getByTestId('category-grid');
expect(grid.className).toContain('md:grid-cols-3');
});
it('renders without crashing when categories is empty', () => {
render(StaplesManager, { props: { categories: [], context: 'onboarding' } });
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,19 @@
import createClient from 'openapi-fetch';
import type { paths } from '$lib/api/schema.d.ts';
import { env } from '$env/dynamic/private';
// Usage in +page.server.ts load functions and form actions:
//
// export const load = async ({ fetch }) => {
// const api = apiClient(fetch);
// const { data, error } = await api.GET('/v1/recipes', { ... });
// };
//
// Always pass SvelteKit's `fetch` so session cookies are forwarded correctly.
export function apiClient(fetch?: typeof globalThis.fetch) {
return createClient<paths>({
baseUrl: env.BACKEND_URL ?? 'http://localhost:8080',
fetch: fetch ?? globalThis.fetch
});
}

View File

@@ -0,0 +1,8 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
benutzer: locals.benutzer!,
haushalt: locals.haushalt!
};
};

View File

@@ -0,0 +1,9 @@
<script lang="ts">
import AppShell from '$lib/nav/AppShell.svelte';
let { data, children } = $props();
</script>
<AppShell appName="Mealprep" householdName={data.haushalt.name}>
{@render children()}
</AppShell>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Mitglieder</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Planer</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Rezepte</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Einstellungen</h1>

View File

@@ -0,0 +1 @@
<h1 class="text-2xl font-medium p-6">Einkaufsliste</h1>

View File

@@ -0,0 +1,5 @@
<script lang="ts">
let { children } = $props();
</script>
{@render children()}

View File

@@ -0,0 +1,47 @@
import { redirect, fail } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
import type { Actions } from './$types';
export const actions = {
default: async ({ request, url, fetch, cookies }) => {
const formData = await request.formData();
const email = (formData.get('email') ?? '').toString().trim();
const password = (formData.get('password') ?? '').toString();
const errors: Record<string, string> = {};
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
errors.email = 'Ungültige E-Mail-Adresse';
}
if (!password) {
errors.password = 'Passwort ist erforderlich';
}
if (Object.keys(errors).length > 0) {
return fail(400, { errors, email });
}
const api = apiClient(fetch);
const { error, response } = await api.POST('/v1/auth/login', {
body: { email, password }
});
if (error) {
return fail(400, {
errors: { form: 'E-Mail oder Passwort ist falsch.' },
email
});
}
const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
if (sessionId) {
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true });
}
const raw = url.searchParams.get('redirect');
const redirectTo = raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/planner';
throw redirect(303, redirectTo);
}
} satisfies Actions;

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import BrandPanel from '$lib/auth/BrandPanel.svelte';
import LoginForm from '$lib/auth/LoginForm.svelte';
let { form } = $props();
</script>
<svelte:head>
<title>Anmelden — Mealprep</title>
</svelte:head>
<!-- Mobile: stacked, Desktop: side by side -->
<div class="flex min-h-screen flex-col md:flex-row">
<BrandPanel />
<div class="flex flex-1 flex-col items-start justify-center px-[20px] py-[24px] md:items-center md:px-[56px] md:py-[48px]">
<div class="w-full max-w-[380px]">
<LoginForm {form} />
</div>
</div>
</div>

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockPost = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ POST: mockPost })
}));
describe('login form action', () => {
let actions: any;
beforeEach(async () => {
mockPost.mockReset();
const mod = await import('./+page.server');
actions = mod.actions;
});
function createEvent(formData: Record<string, string>, searchParams = '') {
const fd = new FormData();
for (const [key, value] of Object.entries(formData)) {
fd.append(key, value);
}
return {
request: { formData: () => Promise.resolve(fd) },
url: new URL(`http://localhost/login${searchParams}`),
fetch: vi.fn(),
cookies: { get: vi.fn(), set: vi.fn() }
} as any;
}
function mockSuccess() {
return {
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue(null) } }
};
}
it('calls POST /v1/auth/login with form data', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent({
email: 'sarah@example.com',
password: 'password123'
}));
} catch {
// redirect throws
}
expect(mockPost).toHaveBeenCalledWith('/v1/auth/login', {
body: {
email: 'sarah@example.com',
password: 'password123'
}
});
});
it('redirects to /planner on success by default', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent({
email: 'sarah@example.com',
password: 'password123'
}));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner');
}
});
it('redirects to ?redirect param when present', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent(
{ email: 'sarah@example.com', password: 'password123' },
'?redirect=%2Frecipes%2Fabc'
));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/recipes/abc');
}
});
it('falls back to /planner when ?redirect= is an absolute URL', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent(
{ email: 'sarah@example.com', password: 'password123' },
'?redirect=https%3A%2F%2Fevil.com'
));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner');
}
});
it('falls back to /planner when ?redirect= is a protocol-relative URL', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createEvent(
{ email: 'sarah@example.com', password: 'password123' },
'?redirect=%2F%2Fevil.com'
));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner');
}
});
it('sets JSESSIONID cookie on successful login', async () => {
mockPost.mockResolvedValue({
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') } }
});
const event = createEvent({ email: 'sarah@example.com', password: 'password123' });
try {
await actions.default(event);
} catch {
// redirect throws
}
expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'abc123', expect.objectContaining({ path: '/', secure: true }));
});
it('rejects empty email with validation error', async () => {
const result = await actions.default(createEvent({
email: '',
password: 'password123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse');
expect(mockPost).not.toHaveBeenCalled();
});
it('rejects empty password with validation error', async () => {
const result = await actions.default(createEvent({
email: 'sarah@example.com',
password: ''
}));
expect(result.status).toBe(400);
expect(result.data.errors.password).toBe('Passwort ist erforderlich');
expect(mockPost).not.toHaveBeenCalled();
});
it('returns fail with form error on API error', async () => {
mockPost.mockResolvedValue({
data: undefined,
error: { status: 401, message: 'Invalid credentials' }
});
const result = await actions.default(createEvent({
email: 'sarah@example.com',
password: 'password123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.form).toBe('E-Mail oder Passwort ist falsch.');
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/login') })
};
});
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('login page', () => {
it('renders the login form', () => {
render(Page);
expect(screen.getByText('Willkommen zurück')).toBeInTheDocument();
});
it('renders the brand panel', () => {
render(Page);
expect(screen.getByText('Mealprep')).toBeInTheDocument();
});
it('sets the page title', () => {
render(Page);
expect(document.title).toBe('Anmelden — Mealprep');
});
it('does not render any navigation chrome', () => {
render(Page);
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
expect(screen.queryByText('Planer')).not.toBeInTheDocument();
expect(screen.queryByText('Rezepte')).not.toBeInTheDocument();
});
it('renders a link to the signup page', () => {
render(Page);
const link = screen.getByRole('link', { name: /registrieren/i });
expect(link).toHaveAttribute('href', '/signup');
});
});

View File

@@ -0,0 +1,51 @@
import { redirect, fail } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
import type { Actions } from './$types';
export const actions = {
default: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
const displayName = (formData.get('displayName') ?? '').toString().trim();
const email = (formData.get('email') ?? '').toString().trim();
const password = (formData.get('password') ?? '').toString();
const errors: Record<string, string> = {};
if (!displayName) {
errors.displayName = 'Name ist erforderlich';
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(email)) {
errors.email = 'Ungültige E-Mail-Adresse';
}
if (password.length < 8) {
errors.password = 'Mindestens 8 Zeichen';
}
if (Object.keys(errors).length > 0) {
return fail(400, { errors, displayName, email });
}
const api = apiClient(fetch);
const { error, response } = await api.POST('/v1/auth/signup', {
body: { displayName, email, password }
});
if (error) {
return fail(400, {
errors: { form: 'Registrierung fehlgeschlagen. Bitte versuche es erneut.' },
displayName,
email
});
}
const sessionId = response.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1];
if (sessionId) {
cookies.set('JSESSIONID', sessionId, { path: '/', httpOnly: true, sameSite: 'lax', secure: true });
}
throw redirect(303, '/household/setup');
}
} satisfies Actions;

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import BrandPanel from '$lib/auth/BrandPanel.svelte';
import SignupForm from '$lib/auth/SignupForm.svelte';
let { form } = $props();
</script>
<svelte:head>
<title>Konto erstellen — Mealprep</title>
</svelte:head>
<!-- Mobile: stacked, Desktop: side by side -->
<div class="flex min-h-screen flex-col md:flex-row">
<BrandPanel />
<div class="flex flex-1 flex-col items-center justify-center px-[20px] py-[24px] md:px-[56px] md:py-[48px]">
<div class="w-full max-w-[380px]">
<SignupForm {form} />
</div>
</div>
</div>

View File

@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockPost = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ POST: mockPost })
}));
describe('signup form action', () => {
let actions: any;
beforeEach(async () => {
mockPost.mockReset();
const mod = await import('./+page.server');
actions = mod.actions;
});
function createRequest(formData: Record<string, string>) {
const fd = new FormData();
for (const [key, value] of Object.entries(formData)) {
fd.append(key, value);
}
return {
request: { formData: () => Promise.resolve(fd) },
fetch: vi.fn(),
cookies: { get: vi.fn(), set: vi.fn() }
} as any;
}
function mockSuccess() {
return {
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue(null) } }
};
}
it('calls POST /v1/auth/signup with form data', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({
displayName: 'Sarah',
email: 'sarah@example.com',
password: 'password123'
}));
} catch {
// redirect throws
}
expect(mockPost).toHaveBeenCalledWith('/v1/auth/signup', {
body: {
displayName: 'Sarah',
email: 'sarah@example.com',
password: 'password123'
}
});
});
it('redirects to /household/setup on success', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({
displayName: 'Sarah',
email: 'sarah@example.com',
password: 'password123'
}));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/household/setup');
}
});
it('sets JSESSIONID cookie on successful signup', async () => {
mockPost.mockResolvedValue({
data: { data: { id: '123' } },
error: undefined,
response: { headers: { get: vi.fn().mockReturnValue('JSESSIONID=xyz789; Path=/; HttpOnly') } }
});
const event = createRequest({ displayName: 'Sarah', email: 'sarah@example.com', password: 'password123' });
try {
await actions.default(event);
} catch {
// redirect throws
}
expect(event.cookies.set).toHaveBeenCalledWith('JSESSIONID', 'xyz789', expect.objectContaining({ path: '/', secure: true }));
});
it('rejects empty displayName with validation error', async () => {
const result = await actions.default(createRequest({
displayName: '',
email: 'sarah@example.com',
password: 'password123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.displayName).toBe('Name ist erforderlich');
expect(mockPost).not.toHaveBeenCalled();
});
it('rejects invalid email with validation error', async () => {
const result = await actions.default(createRequest({
displayName: 'Sarah',
email: 'notanemail',
password: 'password123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.email).toBe('Ungültige E-Mail-Adresse');
expect(mockPost).not.toHaveBeenCalled();
});
it('rejects short password with validation error', async () => {
const result = await actions.default(createRequest({
displayName: 'Sarah',
email: 'sarah@example.com',
password: 'short'
}));
expect(result.status).toBe(400);
expect(result.data.errors.password).toBe('Mindestens 8 Zeichen');
expect(mockPost).not.toHaveBeenCalled();
});
it('handles missing form fields without crashing', async () => {
const fd = new FormData();
const event = {
request: { formData: () => Promise.resolve(fd) },
fetch: vi.fn(),
cookies: { get: vi.fn(), set: vi.fn() }
} as any;
const result = await actions.default(event);
expect(result.status).toBe(400);
expect(result.data.errors.displayName).toBe('Name ist erforderlich');
expect(mockPost).not.toHaveBeenCalled();
});
it('returns fail with error message on API error', async () => {
mockPost.mockResolvedValue({
data: undefined,
error: { status: 409, message: 'Email already registered' }
});
const result = await actions.default(createRequest({
displayName: 'Sarah',
email: 'sarah@example.com',
password: 'password123'
}));
expect(result.status).toBe(400);
expect(result.data.errors.form).toBe('Registrierung fehlgeschlagen. Bitte versuche es erneut.');
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
vi.mock('$app/stores', async () => {
const { readable } = await import('svelte/store');
return {
page: readable({ url: new URL('http://localhost/signup') })
};
});
describe('signup page', () => {
it('renders the signup form', () => {
render(Page);
expect(screen.getByText('Konto erstellen')).toBeInTheDocument();
});
it('renders the brand panel', () => {
render(Page);
expect(screen.getByText('Mealprep')).toBeInTheDocument();
});
it('sets the page title', () => {
render(Page);
expect(document.title).toBe('Konto erstellen — Mealprep');
});
it('does not render any navigation chrome', () => {
render(Page);
// No nav element should exist
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
// No app shell nav links
expect(screen.queryByText('Planer')).not.toBeInTheDocument();
expect(screen.queryByText('Rezepte')).not.toBeInTheDocument();
expect(screen.queryByText('Einkauf')).not.toBeInTheDocument();
expect(screen.queryByText('Einstellungen')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
redirect(302, '/planner');
};

View File

@@ -0,0 +1 @@
<p>Weiterleitung...</p>

View File

@@ -0,0 +1,7 @@
<svelte:head>
<title>Mitglieder einladen — Mealplan</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--color-page)]">
<p class="text-[var(--color-text-muted)]">A4 — Mitglieder einladen (coming soon)</p>
</div>

View File

@@ -0,0 +1,39 @@
import { redirect, fail } from '@sveltejs/kit';
import { apiClient } from '$lib/server/api';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.haushalt?.id) {
throw redirect(303, '/planner');
}
return {};
};
export const actions = {
default: async ({ request, fetch }) => {
const formData = await request.formData();
const name = (formData.get('name') ?? '').toString().trim();
if (!name) {
return fail(400, { errors: { name: 'Haushaltsname ist erforderlich' }, name: '' });
}
if (name.length > 100) {
return fail(400, { errors: { name: 'Haushaltsname darf maximal 100 Zeichen lang sein' }, name });
}
const api = apiClient(fetch);
const { data, error } = await api.POST('/v1/households', {
body: { name }
});
if (error || !data?.data) {
return fail(500, {
errors: { form: 'Haushalt konnte nicht erstellt werden. Bitte versuche es erneut.' },
name
});
}
throw redirect(303, '/household/staples?ctx=onboarding');
}
} satisfies Actions;

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import ProgressSidebar from '$lib/components/ProgressSidebar.svelte';
import HouseholdSetupForm from '$lib/onboarding/HouseholdSetupForm.svelte';
type FormResult = {
errors?: Record<string, string>;
name?: string;
} | null;
let { form = null }: { form?: FormResult } = $props();
</script>
<svelte:head>
<title>Haushalt einrichten — Mealplan</title>
</svelte:head>
<div class="flex min-h-screen bg-[var(--color-page)]">
<!-- Desktop progress sidebar — hidden on mobile -->
<aside
class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]"
>
<ProgressSidebar currentStep={1} />
</aside>
<!-- Form area -->
<main class="flex flex-1 flex-col justify-center">
<!-- Mobile: step indicator (visible only on mobile) -->
<div class="md:hidden px-[20px] pt-[24px] pb-[0]">
<p class="text-[10px] font-medium tracking-[.08em] uppercase text-[var(--color-text-muted)]">
Schritt 1 von 3
</p>
</div>
<!-- Form -->
<div class="px-[20px] py-[24px] md:px-[56px] md:py-[48px]">
<div class="max-w-[420px]">
<HouseholdSetupForm {form} />
</div>
</div>
</main>
</div>

View File

@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockPost = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ POST: mockPost })
}));
describe('household setup — load', () => {
let load: any;
beforeEach(async () => {
const mod = await import('./+page.server');
load = mod.load;
});
it('redirects to /planner when user already has a household', async () => {
const event = {
locals: {
benutzer: { id: '1', name: 'Sarah', rolle: 'planer' },
haushalt: { id: 'household-123', name: 'Smith family' }
}
} as any;
try {
await load(event);
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/planner');
}
});
it('allows access when user has no household', async () => {
const event = {
locals: {
benutzer: { id: '1', name: 'Sarah', rolle: 'planer' },
haushalt: { id: undefined, name: 'Kein Haushalt' }
}
} as any;
const result = await load(event);
expect(result).toBeDefined();
});
});
describe('household setup — form action', () => {
let actions: any;
beforeEach(async () => {
mockPost.mockReset();
const mod = await import('./+page.server');
actions = mod.actions;
});
function createRequest(formData: Record<string, string>) {
const fd = new FormData();
for (const [key, value] of Object.entries(formData)) {
fd.append(key, value);
}
return {
request: { formData: () => Promise.resolve(fd) },
fetch: vi.fn(),
cookies: { get: vi.fn(), set: vi.fn() }
} as any;
}
function mockSuccess() {
return {
data: { data: { id: 'hh-123', name: 'Smith family', members: [] } },
error: undefined
};
}
it('calls POST /v1/households with the household name', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({ name: 'Smith family' }));
} catch {
// redirect throws
}
expect(mockPost).toHaveBeenCalledWith('/v1/households', {
body: { name: 'Smith family' }
});
});
it('redirects to /household/staples on success', async () => {
mockPost.mockResolvedValue(mockSuccess());
try {
await actions.default(createRequest({ name: 'Smith family' }));
expect.unreachable();
} catch (e: any) {
expect(e.status).toBe(303);
expect(e.location).toBe('/household/staples?ctx=onboarding');
}
});
it('returns fail(400) when name is empty', async () => {
const result = await actions.default(createRequest({ name: '' }));
expect(result.status).toBe(400);
expect(result.data.errors.name).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it('returns fail(400) when name is whitespace only', async () => {
const result = await actions.default(createRequest({ name: ' ' }));
expect(result.status).toBe(400);
expect(result.data.errors.name).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it('echoes name back on validation error', async () => {
const result = await actions.default(createRequest({ name: '' }));
expect(result.data.name).toBe('');
});
it('returns fail(400) when name exceeds 100 characters', async () => {
const longName = 'a'.repeat(101);
const result = await actions.default(createRequest({ name: longName }));
expect(result.status).toBe(400);
expect(result.data.errors.name).toBeTruthy();
expect(mockPost).not.toHaveBeenCalled();
});
it('accepts name at exactly 100 characters', async () => {
mockPost.mockResolvedValue(mockSuccess());
const maxName = 'a'.repeat(100);
try {
await actions.default(createRequest({ name: maxName }));
} catch {
// redirect throws
}
expect(mockPost).toHaveBeenCalled();
});
it('returns fail with form error on API failure', async () => {
mockPost.mockResolvedValue({
data: undefined,
error: { status: 500, message: 'Internal server error' }
});
const result = await actions.default(createRequest({ name: 'Smith family' }));
expect(result.status).toBe(500);
expect(result.data.errors.form).toBeTruthy();
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
vi.mock('$app/forms', () => ({
enhance: () => ({ destroy: () => {} })
}));
describe('household setup page', () => {
it('renders the form heading', () => {
render(Page);
expect(screen.getByRole('heading', { name: 'Haushalt benennen' })).toBeInTheDocument();
});
it('renders the household name input', () => {
render(Page);
expect(screen.getByLabelText('Haushaltsname')).toBeInTheDocument();
});
it('renders the continue button', () => {
render(Page);
expect(screen.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
});
it('renders the ProgressSidebar with step 1 active', () => {
render(Page);
const step1 = screen.getByTestId('step-1');
expect(step1).toHaveAttribute('aria-current', 'step');
});
it('renders steps 2 and 3 as future steps', () => {
render(Page);
expect(screen.getByTestId('step-2')).not.toHaveAttribute('aria-current');
expect(screen.getByTestId('step-3')).not.toHaveAttribute('aria-current');
});
it('does not render app navigation chrome', () => {
render(Page);
// No nav links like Planer or Rezepte (those are app shell nav items)
expect(screen.queryByText('Planer')).not.toBeInTheDocument();
expect(screen.queryByText('Rezepte')).not.toBeInTheDocument();
});
it('sets the page title', () => {
render(Page);
expect(document.title).toBe('Haushalt einrichten — Mealplan');
});
it('renders the mobile step indicator text', () => {
render(Page);
expect(screen.getByText(/schritt 1 von 3/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import type { PageServerLoad } from './$types';
import { apiClient } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, url }) => {
const api = apiClient(fetch);
const [categoriesResult, ingredientsResult] = await Promise.all([
api.GET('/v1/ingredient-categories'),
api.GET('/v1/ingredients')
]);
const rawCategories = categoriesResult.data ?? [];
const rawIngredients = ingredientsResult.data ?? [];
const categories = rawCategories.map((cat) => ({
id: cat.id!,
name: cat.name!,
ingredients: rawIngredients
.filter((ing) => ing.category?.id === cat.id)
.map((ing) => ({
id: ing.id!,
name: ing.name!,
isStaple: ing.isStaple ?? false
}))
}));
return { categories, ctx: url.searchParams.get('ctx') };
};

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import ProgressSidebar from '$lib/components/ProgressSidebar.svelte';
import StaplesManager from '$lib/onboarding/StaplesManager.svelte';
type Category = { id: string; name: string; ingredients: { id: string; name: string; isStaple: boolean }[] };
let { data }: { data: { categories: Category[]; ctx: string | null } } = $props();
const isOnboarding = $derived(data.ctx === 'onboarding');
</script>
<svelte:head>
<title>Vorräte einrichten — Mealplan</title>
</svelte:head>
{#if isOnboarding}
<div class="flex min-h-screen bg-[var(--color-page)]">
<!-- Desktop sidebar -->
<aside class="hidden md:flex w-[300px] flex-shrink-0 flex-col bg-[var(--color-surface)] border-r border-[var(--color-border)] p-[40px_28px]">
<ProgressSidebar currentStep={2} />
</aside>
<!-- Main area -->
<main class="flex flex-1 flex-col">
<!-- Mobile step indicator -->
<p class="md:hidden px-6 pt-6 text-sm text-[var(--color-text-muted)]">Schritt 2 von 3</p>
<!-- Content -->
<div class="flex-1 p-6">
<StaplesManager categories={data.categories} context="onboarding" />
</div>
<!-- Footer navigation -->
<div class="flex justify-between p-6">
<a
href="/planner"
class="font-sans text-[13px] font-medium tracking-[0.04em] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
>Überspringen</a>
<a
href="/household/invite"
class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white"
>Weiter</a>
</div>
</main>
</div>
{:else}
<div class="flex min-h-screen flex-col bg-[var(--color-page)]">
<h1 class="mb-[8px] font-[var(--font-display)] text-[18px] font-medium md:text-[28px] text-[var(--color-text)]">Vorräte</h1>
<StaplesManager categories={data.categories} context="settings" />
</div>
{/if}

View File

@@ -0,0 +1,33 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { apiClient } from '$lib/server/api';
export const PATCH: RequestHandler = async ({ request, fetch, locals }) => {
if (locals.benutzer?.rolle !== 'planer') {
return json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const { id, isStaple } = body;
if (!id) {
return json({ error: 'id is required' }, { status: 400 });
}
if (typeof isStaple !== 'boolean') {
return json({ error: 'isStaple must be a boolean' }, { status: 400 });
}
const api = apiClient(fetch);
const { error } = await api.PATCH('/v1/ingredients/{id}', {
params: { path: { id } },
body: { isStaple }
});
if (error) {
const status = (error as { status?: number }).status ?? 500;
return json({ error: 'Failed to update ingredient' }, { status });
}
return new Response(null, { status: 204 });
};

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockGet = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ GET: mockGet })
}));
const mockCategories = [
{ id: 'cat-1', name: 'Öle & Fette' },
{ id: 'cat-2', name: 'Gewürze' }
];
const mockIngredients = [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true, category: { id: 'cat-1', name: 'Öle & Fette' } },
{ id: 'ing-2', name: 'Butter', isStaple: false, category: { id: 'cat-1', name: 'Öle & Fette' } },
{ id: 'ing-3', name: 'Salz', isStaple: true, category: { id: 'cat-2', name: 'Gewürze' } }
];
describe('household staples page — load', () => {
let load: any;
beforeEach(async () => {
mockGet.mockReset();
vi.resetModules();
const mod = await import('./+page.server');
load = mod.load;
});
function mockApiResponses() {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredient-categories') {
return Promise.resolve({ data: mockCategories, error: undefined });
}
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
});
}
it('passes ctx from url searchParams into returned data', async () => {
mockApiResponses();
const url = new URL('http://localhost/household/staples?ctx=onboarding');
const result = await load({ fetch: vi.fn(), url } as any);
expect(result.ctx).toBe('onboarding');
});
it('returns ctx as null when no ctx param is present', async () => {
mockApiResponses();
const url = new URL('http://localhost/household/staples');
const result = await load({ fetch: vi.fn(), url } as any);
expect(result.ctx).toBeNull();
});
it('fetches both categories and ingredients in parallel', async () => {
mockApiResponses();
await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
const calls = mockGet.mock.calls.map((c) => c[0]);
expect(calls).toContain('/v1/ingredient-categories');
expect(calls).toContain('/v1/ingredients');
});
it('groups ingredients by category id', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
expect(result.categories).toHaveLength(2);
const oele = result.categories.find((c: any) => c.id === 'cat-1');
expect(oele.ingredients).toHaveLength(2);
expect(oele.ingredients[0].name).toBe('Olivenöl');
});
it('preserves isStaple flag on each ingredient', async () => {
mockApiResponses();
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
const oele = result.categories.find((c: any) => c.id === 'cat-1');
expect(oele.ingredients.find((i: any) => i.name === 'Olivenöl').isStaple).toBe(true);
expect(oele.ingredients.find((i: any) => i.name === 'Butter').isStaple).toBe(false);
});
it('categories without ingredients are included with empty array', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/v1/ingredient-categories') {
return Promise.resolve({ data: [...mockCategories, { id: 'cat-3', name: 'Leer' }], error: undefined });
}
if (path === '/v1/ingredients') {
return Promise.resolve({ data: mockIngredients, error: undefined });
}
});
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
const leer = result.categories.find((c: any) => c.id === 'cat-3');
expect(leer).toBeDefined();
expect(leer.ingredients).toHaveLength(0);
});
it('returns empty categories when API fails', async () => {
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
const result = await load({ fetch: vi.fn(), url: new URL('http://localhost/household/staples') } as any);
expect(result.categories).toEqual([]);
});
});

View File

@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
const mockCategories = [
{
id: 'cat-1',
name: 'Öle & Fette',
ingredients: [
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
{ id: 'ing-2', name: 'Butter', isStaple: false }
]
}
];
describe('staples page — onboarding context (?ctx=onboarding)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders ProgressSidebar with step 2 active', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.getByTestId('step-2')).toHaveAttribute('aria-current', 'step');
});
it('renders Continue button linking to /household/invite', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
const continueLink = screen.getByRole('link', { name: /weiter/i });
expect(continueLink).toHaveAttribute('href', '/household/invite');
});
it('renders Skip button linking to /planner', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
const skipLink = screen.getByRole('link', { name: /überspringen/i });
expect(skipLink).toHaveAttribute('href', '/planner');
});
it('renders the StaplesManager with categories', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
});
it('sets the page title', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(document.title).toBe('Vorräte einrichten — Mealplan');
});
it('renders mobile step indicator Schritt 2 von 3', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: 'onboarding' } } });
expect(screen.getByText(/schritt 2 von 3/i)).toBeInTheDocument();
});
});
describe('staples page — settings context (no ctx)', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('does not render ProgressSidebar', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.queryByTestId('step-1')).not.toBeInTheDocument();
});
it('does not render Continue or Skip buttons', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.queryByRole('link', { name: /weiter/i })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: /überspringen/i })).not.toBeInTheDocument();
});
it('renders a settings heading', () => {
render(Page, { props: { data: { categories: mockCategories, ctx: null } } });
expect(screen.getByRole('heading', { name: /vorräte/i })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$env/dynamic/private', () => ({
env: { BACKEND_URL: 'http://localhost:8080' }
}));
const mockPatch = vi.fn();
vi.mock('$lib/server/api', () => ({
apiClient: () => ({ PATCH: mockPatch })
}));
describe('household staples PATCH handler', () => {
let PATCH: any;
beforeEach(async () => {
mockPatch.mockReset();
const mod = await import('./+server');
PATCH = mod.PATCH;
});
function createRequest(body: object, rolle: 'planer' | 'mitglied' = 'planer') {
return {
request: {
json: () => Promise.resolve(body)
},
fetch: vi.fn(),
locals: { benutzer: { rolle } }
} as any;
}
it('calls backend PATCH /v1/ingredients/{id} with isStaple', async () => {
mockPatch.mockResolvedValue({ data: {}, error: undefined });
await PATCH(createRequest({ id: 'ing-1', isStaple: true }));
expect(mockPatch).toHaveBeenCalledWith('/v1/ingredients/{id}', {
params: { path: { id: 'ing-1' } },
body: { isStaple: true }
});
});
it('returns 204 on success', async () => {
mockPatch.mockResolvedValue({ data: {}, error: undefined });
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: true }));
expect(response.status).toBe(204);
});
it('returns 500 when backend returns a 500 error', async () => {
mockPatch.mockResolvedValue({ data: undefined, error: { status: 500, message: 'error' } });
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: false }));
expect(response.status).toBe(500);
});
it('forwards backend 404 status when ingredient not found', async () => {
mockPatch.mockResolvedValue({ data: undefined, error: { status: 404 } });
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: false }));
expect(response.status).toBe(404);
});
it('returns 400 when id is missing', async () => {
const response = await PATCH(createRequest({ isStaple: true }));
expect(response.status).toBe(400);
expect(mockPatch).not.toHaveBeenCalled();
});
it('returns 400 when isStaple is missing', async () => {
const response = await PATCH(createRequest({ id: 'ing-1' }));
expect(response.status).toBe(400);
expect(mockPatch).not.toHaveBeenCalled();
});
it('returns 403 when caller has mitglied role', async () => {
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: true }, 'mitglied'));
expect(response.status).toBe(403);
expect(mockPatch).not.toHaveBeenCalled();
});
it('returns 400 when isStaple is not a boolean', async () => {
const response = await PATCH(createRequest({ id: 'ing-1', isStaple: 'yes' }));
expect(response.status).toBe(400);
expect(mockPatch).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

24
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,24 @@
import adapter from '@sveltejs/adapter-node';
import { relative, sep } from 'node:path';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// defaults to rune mode for the project, except for `node_modules`. Can be removed in svelte 6.
runes: ({ filename }) => {
const relativePath = relative(import.meta.dirname, filename);
const pathSegments = relativePath.toLowerCase().split(sep);
const isExternalLibrary = pathSegments.includes('node_modules');
return isExternalLibrary ? undefined : true;
}
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

21
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["@types/node"]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
}

18
frontend/vite.config.ts Normal file
View File

@@ -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']
}
});

View File

@@ -0,0 +1,394 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>J1 — Add a Recipe</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
/* Header */
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;background:var(--green-tint);color:var(--green-dark);}
/* Sections */
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
/* Journey headers */
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-p{background:var(--purple-tint);border:1px solid #CECBF6;}.jh-p .jn{color:var(--purple);}.jh-p p,.jh-p .fl{color:var(--purple-dark);}
.jh-g{background:var(--green-tint);border:1px solid var(--green-light);}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
.jh-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}.jh-y .jn{color:var(--yellow-dark);}.jh-y p,.jh-y .fl{color:var(--yellow-text);}
.jh-o{background:var(--orange-tint);border:1px solid #FBCDA4;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.jh-b{background:var(--blue-tint);border:1px solid var(--blue-light);}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
/* Screen block */
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
/* Preview container */
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
/* Phone frame - 320px */
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
/* Desktop frame - 1040px */
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
/* Shared nav components */
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;}
.mtb-t{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.mi{width:32px;height:32px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);display:flex;align-items:center;justify-content:center;font-size:13px;color:var(--color-text-muted);flex-shrink:0;}.mi.gn{background:var(--green);border-color:var(--green);color:#fff;}
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;}
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;}.mt-i.a .mt-ic{background:var(--green-tint);}.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}.mt-i.a .mt-l{color:var(--green-dark);}
/* Desktop sidebar - 224px per nav-spec */
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:12px;}.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
.dsb-nav{padding:12px 10px;flex:1;}.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;cursor:default;}.dsb-ni.a{background:var(--green-tint);color:var(--green-dark);font-weight:500;}.dsb-nc{font-size:13px;width:18px;text-align:center;}
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
.dtb{padding:12px 24px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;}
.dtb-r{display:flex;align-items:center;gap:8px;}
.dab{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:7px 16px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;}
.dab-b{background:var(--blue);}
/* Shared form */
.fi{width:100%;font-family:var(--font-sans);font-size:14px;padding:10px 12px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-page);color:var(--color-text);outline:none;}
.fl{font-size:12px;font-weight:500;color:var(--color-text);margin-bottom:6px;display:block;}
.fg{margin-bottom:16px;}
.bp{font-family:var(--font-sans);font-size:14px;font-weight:500;padding:12px 24px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;cursor:pointer;width:100%;}
.bg{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:10px 20px;border-radius:var(--radius-md);background:var(--color-subtle);color:var(--color-text-muted);border:1px solid var(--color-border);cursor:pointer;}
/* Tags */
.tc{display:inline-flex;font-size:12px;font-weight:500;padding:6px 12px;border-radius:20px;border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);cursor:pointer;margin:0 4px 4px 0;user-select:none;}.tc.s{background:var(--green-tint);color:var(--green-dark);border-color:var(--green-light);}
.badge{font-size:9px;font-weight:500;padding:2px 6px;border-radius:3px;display:inline-block;}.badge-g{background:var(--green-tint);color:var(--green-dark);}.badge-y{background:var(--yellow-tint);color:var(--yellow-text);}.badge-m{background:var(--color-subtle);color:var(--color-text-muted);}
/* Ingredient rows */
.ir{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--color-subtle);}.ir:last-child{border-bottom:none;}.ir-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);width:55px;flex-shrink:0;text-align:right;}.ir-n{font-size:13px;color:var(--color-text);flex:1;}.ir-x{font-size:14px;color:var(--color-border);cursor:pointer;width:20px;text-align:center;}
/* Checklist */
.ck{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--color-subtle);cursor:pointer;}.ck:last-child{border-bottom:none;}.ck-b{width:22px;height:22px;border-radius:4px;border:2px solid var(--color-border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;}.ck.d .ck-b{background:var(--green);border-color:var(--green);color:#fff;}.ck-c{flex:1;}.ck-n{font-size:14px;color:var(--color-text);}.ck.d .ck-n{text-decoration:line-through;color:var(--color-text-muted);}.ck-s{font-size:10px;color:var(--color-text-muted);}.ck-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);flex-shrink:0;}
/* Suggestion cards */
.sg{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;display:flex;align-items:center;gap:10px;}.sg-r{font-family:var(--font-display);font-size:16px;font-weight:300;color:var(--color-text-muted);width:20px;text-align:center;flex-shrink:0;}.sg-b{flex:1;}.sg-n{font-family:var(--font-display);font-size:13px;font-weight:400;color:var(--color-text);margin-bottom:2px;}.sg-i{font-size:10px;color:var(--color-text-muted);}.sg-w{font-size:9px;color:var(--green-dark);background:var(--green-tint);padding:2px 6px;border-radius:3px;display:inline-block;margin-top:3px;}.sg-p{font-size:11px;font-weight:500;color:var(--green);flex-shrink:0;}
/* Eyebrow labels */
.eye{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
/* Agent table */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* LLM instruction box */
.llm{background:var(--color-page);border:2px solid var(--green);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--green-dark);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<div class="jh jh-g">
<div class="jn">J1</div>
<div><h2>Add a recipe</h2><p>Save recipe with ingredients, steps, tags, and hero image.</p><div class="fl">B1 → B3 → B1 · Planner · Min: effort + 1 category tag</div></div>
</div>
<!-- ═══ B1 RECIPE LIBRARY ═══ -->
<div class="scr" id="b1">
<div class="scr-head"><h3>Recipe library</h3><span class="scr-id">B1</span></div>
<div class="scr-desc">Browse all recipes. Desktop: app sidebar + topbar with search bar and filter chips + 4-column card grid. The grid fills the content area naturally — not wrapped in an additional card or panel.</div>
<div class="scr-var"><strong>V2 · Card grid</strong> — mobile 2-col, desktop sidebar + 4-col with filters</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div class="mtb"><div class="mtb-t">Recipes</div><div style="display:flex;gap:6px;"><div class="mi">🔍</div><div class="mi gn">+</div></div></div>
<div style="padding:10px 12px;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;"><div style="height:64px;background:var(--green-tint);display:flex;align-items:center;justify-content:center;font-size:28px;">🍝</div><div style="padding:8px 10px;"><div style="font-family:var(--font-display);font-size:12px;margin-bottom:3px;">Tomato pasta</div><div style="font-size:9px;color:var(--color-text-muted);">45 min · Easy</div></div></div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;"><div style="height:64px;background:var(--yellow-tint);display:flex;align-items:center;justify-content:center;font-size:28px;">🍗</div><div style="padding:8px 10px;"><div style="font-family:var(--font-display);font-size:12px;margin-bottom:3px;">Chicken stir-fry</div><div style="font-size:9px;color:var(--color-text-muted);">25 min · Easy</div></div></div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;"><div style="height:64px;background:var(--blue-tint);display:flex;align-items:center;justify-content:center;font-size:28px;">🐟</div><div style="padding:8px 10px;"><div style="font-family:var(--font-display);font-size:12px;margin-bottom:3px;">Salmon teriyaki</div><div style="font-size:9px;color:var(--color-text-muted);">35 min · Medium</div></div></div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;"><div style="height:64px;background:var(--green-tint);display:flex;align-items:center;justify-content:center;font-size:28px;">🍄</div><div style="padding:8px 10px;"><div style="font-family:var(--font-display);font-size:12px;margin-bottom:3px;">Mushroom risotto</div><div style="font-size:9px;color:var(--color-text-muted);">50 min · Medium</div></div></div>
</div>
</div>
<div class="mbt"><div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Planner</div></div><div class="mt-i a"><div class="mt-ic">📖</div><div class="mt-l">Recipes</div></div><div class="mt-i"><div class="mt-ic">🛒</div><div class="mt-l">Shopping</div></div><div class="mt-i"><div class="mt-ic">⚙️</div><div class="mt-l">Settings</div></div></div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥗</div><div class="dsb-nm">Mealplan</div></div><div class="dsb-sub">Smith household</div></div>
<div class="dsb-nav"><div style="margin-bottom:16px;"><div class="dsb-nl">Plan</div><div class="dsb-ni"><span class="dsb-nc">📅</span>Planner</div><div class="dsb-ni a"><span class="dsb-nc">📖</span>Recipes</div><div class="dsb-ni"><span class="dsb-nc">🛒</span>Shopping</div></div><div><div class="dsb-nl">Account</div><div class="dsb-ni"><span class="dsb-nc">👨‍👩‍👧</span>Household</div><div class="dsb-ni"><span class="dsb-nc">⚙️</span>Settings</div></div></div>
</div>
<div class="dm">
<div class="dtb"><div class="dtb-t">Recipe library</div><div class="dtb-r"><input class="fi" style="width:220px;font-size:12px;padding:7px 12px;" placeholder="🔍 Search recipes…"/><button class="dab">+ Add recipe</button></div></div>
<div style="flex:1;padding:20px 24px;overflow-y:auto;">
<!-- Filter chips -->
<div style="display:flex;gap:5px;margin-bottom:16px;">
<div style="font-size:11px;font-weight:500;padding:5px 14px;border-radius:12px;background:var(--green-tint);color:var(--green-dark);">All (24)</div>
<div style="font-size:11px;font-weight:500;padding:5px 14px;border-radius:12px;border:1px solid var(--color-border);color:var(--color-text-muted);">Easy</div>
<div style="font-size:11px;font-weight:500;padding:5px 14px;border-radius:12px;border:1px solid var(--color-border);color:var(--color-text-muted);">Medium</div>
<div style="font-size:11px;font-weight:500;padding:5px 14px;border-radius:12px;border:1px solid var(--color-border);color:var(--color-text-muted);">Chicken</div>
<div style="font-size:11px;font-weight:500;padding:5px 14px;border-radius:12px;border:1px solid var(--color-border);color:var(--color-text-muted);">Fish</div>
<div style="font-size:11px;font-weight:500;padding:5px 14px;border-radius:12px;border:1px solid var(--color-border);color:var(--color-text-muted);">Veggie</div>
</div>
<!-- 4-col grid -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;">
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;cursor:pointer;"><div style="height:100px;background:var(--green-tint);display:flex;align-items:center;justify-content:center;font-size:32px;">🍝</div><div style="padding:10px 12px;"><div style="font-family:var(--font-display);font-size:14px;font-weight:400;margin-bottom:4px;">Slow-roasted tomato pasta</div><div style="font-size:10px;color:var(--color-text-muted);margin-bottom:6px;">45 min · Easy · Last cooked 3 days ago</div><div style="display:flex;gap:3px;"><span class="badge badge-g">Vegetarian</span><span class="badge badge-y">Child-friendly</span></div></div></div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;"><div style="height:100px;background:var(--yellow-tint);display:flex;align-items:center;justify-content:center;font-size:32px;">🍗</div><div style="padding:10px 12px;"><div style="font-family:var(--font-display);font-size:14px;font-weight:400;margin-bottom:4px;">Chicken stir-fry</div><div style="font-size:10px;color:var(--color-text-muted);margin-bottom:6px;">25 min · Easy · 5 days ago</div><div style="display:flex;gap:3px;"><span class="badge badge-m">Chicken</span></div></div></div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;"><div style="height:100px;background:var(--blue-tint);display:flex;align-items:center;justify-content:center;font-size:32px;">🐟</div><div style="padding:10px 12px;"><div style="font-family:var(--font-display);font-size:14px;font-weight:400;margin-bottom:4px;">Salmon teriyaki with rice</div><div style="font-size:10px;color:var(--color-text-muted);margin-bottom:6px;">35 min · Medium · 2 weeks ago</div><div style="display:flex;gap:3px;"><span class="badge badge-m">Fish</span></div></div></div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;"><div style="height:100px;background:var(--green-tint);display:flex;align-items:center;justify-content:center;font-size:32px;">🍄</div><div style="padding:10px 12px;"><div style="font-family:var(--font-display);font-size:14px;font-weight:400;margin-bottom:4px;">Mushroom risotto</div><div style="font-size:10px;color:var(--color-text-muted);margin-bottom:6px;">50 min · Medium · 2 weeks ago</div><div style="display:flex;gap:3px;"><span class="badge badge-g">Vegetarian</span></div></div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>B1 · Recipe library</h4>
<pre>/* Desktop: 224px sidebar + topbar (search 220px + Add button) + content: filter chips row + 4-col grid.
* Desktop cards are richer: 100px image area + name (Fraunces 14px) + meta + tags row.
* Mobile: 2-col, 64px image, no tags on cards (too small).
* Filter chips: from tag table. Active: green-tint. 12px border-radius.
* Grid fills the content area directly — no wrapping card.
* Card click → B2 (recipe detail). Add button → B3 (empty form). */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop</td></tr>
<tr><td>Grid</td><td>4 columns, 12px gap, content padding 20px 24px</td><td>Cards: radius-lg, border-default, overflow hidden</td></tr>
<tr><td>Card image</td><td>100px height. hero_image_url → object-fit:cover. NULL → tint + emoji.</td><td>Tint color based on first protein tag</td></tr>
<tr><td>Card content</td><td>10px 12px padding. Name: Fraunces 14px. Meta: 10px muted. Tags: badge row.</td><td>meta shows cook time + effort + "last cooked X ago"</td></tr>
<tr><td>Filter chips</td><td>11px/500, 5px 14px pad, 12px radius</td><td>Active: green-tint bg + green-dark text. Others: border-default.</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>Grid</td><td>2 columns, 8px gap</td><td>Cards: 64px image, 12px name, 9px meta. No tags.</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ B3 ADD/EDIT RECIPE ═══ -->
<div class="scr" id="b3">
<div class="scr-head"><h3>Add / edit recipe</h3><span class="scr-id">B3</span></div>
<div class="scr-desc">Single form for add + edit. Mobile: V1 single scroll. Desktop: V5 sidebar + topbar + split content (form left, tags + live preview panel right). The tags panel is a natural sidebar within the content area, not a floating card.</div>
<div class="scr-var"><strong>V1 mobile / V5 desktop</strong> — form left, tags + preview right on desktop</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:45</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="padding:10px 16px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;">
<div style="font-size:13px;color:var(--green);font-weight:500;">← Recipes</div>
<div style="font-family:var(--font-display);font-size:16px;font-weight:500;">New recipe</div>
<div style="font-size:13px;color:var(--green);font-weight:500;">Save</div>
</div>
<div style="padding:16px;">
<div style="height:56px;background:var(--color-subtle);border:1px dashed var(--color-border);border-radius:var(--radius-lg);display:flex;align-items:center;justify-content:center;margin-bottom:16px;"><div style="text-align:center;"><div style="font-size:14px;color:var(--color-border);">📷</div><div style="font-size:9px;color:var(--color-text-muted);">Add photo</div></div></div>
<div class="fg"><label class="fl">Recipe name</label><input class="fi" value="Slow-roasted tomato pasta"/></div>
<div style="display:flex;gap:8px;">
<div class="fg" style="flex:1;"><label class="fl">Serves</label><input class="fi" type="number" value="4"/></div>
<div class="fg" style="flex:1;"><label class="fl">Cook time</label><input class="fi" value="45 min"/></div>
</div>
<div class="eye" style="margin-bottom:6px;">Ingredients</div>
<div style="border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:4px 12px;margin-bottom:6px;">
<div class="ir"><div class="ir-q">500g</div><div class="ir-n">Cherry tomatoes</div><div class="ir-x">×</div></div>
<div class="ir"><div class="ir-q">300g</div><div class="ir-n">Penne pasta</div><div class="ir-x">×</div></div>
<div class="ir"><div class="ir-q">3 cloves</div><div class="ir-n">Garlic</div><div class="ir-x">×</div></div>
</div>
<button class="bg" style="width:100%;font-size:11px;padding:7px;margin-bottom:12px;">+ Add ingredient</button>
<div class="eye" style="margin-bottom:6px;">Tags (required)</div>
<div style="font-size:11px;color:var(--color-text-muted);margin-bottom:4px;">Effort</div>
<div style="display:flex;gap:4px;margin-bottom:10px;"><span class="tc s">Easy</span><span class="tc">Medium</span><span class="tc">Hard</span></div>
<div style="font-size:11px;color:var(--color-text-muted);margin-bottom:4px;">Category (pick ≥ 1)</div>
<div style="margin-bottom:14px;"><span class="tc">Chicken</span><span class="tc">Fish</span><span class="tc s">Vegetarian</span><span class="tc s">Child-friendly</span><span class="tc">Pasta</span></div>
<button class="bp">Save recipe</button>
</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk" style="min-height:540px;">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥗</div><div class="dsb-nm">Mealplan</div></div><div class="dsb-sub">Smith household</div></div>
<div class="dsb-nav"><div><div class="dsb-nl">Plan</div><div class="dsb-ni"><span class="dsb-nc">📅</span>Planner</div><div class="dsb-ni a"><span class="dsb-nc">📖</span>Recipes</div><div class="dsb-ni"><span class="dsb-nc">🛒</span>Shopping</div></div></div>
</div>
<div class="dm">
<div class="dtb">
<div style="display:flex;align-items:center;gap:8px;"><span style="font-size:13px;color:var(--color-text-muted);">← Recipes</span><span style="color:var(--color-border);">/</span><span class="dtb-t">New recipe</span></div>
<div class="dtb-r"><button class="bg" style="padding:7px 14px;font-size:12px;">Cancel</button><button class="dab">Save recipe</button></div>
</div>
<div style="flex:1;display:flex;overflow:hidden;">
<!-- Left: form -->
<div style="flex:1;padding:24px;overflow-y:auto;border-right:1px solid var(--color-border);">
<div style="height:80px;background:var(--color-subtle);border:1px dashed var(--color-border);border-radius:var(--radius-lg);display:flex;align-items:center;justify-content:center;margin-bottom:20px;cursor:pointer;"><span style="font-size:13px;color:var(--color-text-muted);">📷 Add hero image</span></div>
<div class="fg"><label class="fl">Recipe name</label><input class="fi" value="Slow-roasted tomato pasta" style="font-size:16px;padding:12px 14px;"/></div>
<div style="display:flex;gap:12px;">
<div class="fg" style="flex:1;"><label class="fl">Serves</label><input class="fi" type="number" value="4"/></div>
<div class="fg" style="flex:1;"><label class="fl">Cook time</label><input class="fi" value="45 min"/></div>
<div class="fg" style="flex:1;"><label class="fl">Prep time</label><input class="fi" placeholder="Optional"/></div>
</div>
<div class="eye" style="margin:8px 0;">Ingredients</div>
<div style="border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:4px 14px;margin-bottom:8px;">
<div class="ir"><div class="ir-q">500g</div><div class="ir-n">Cherry tomatoes</div><div class="ir-x">×</div></div>
<div class="ir"><div class="ir-q">300g</div><div class="ir-n">Penne pasta</div><div class="ir-x">×</div></div>
<div class="ir"><div class="ir-q">3 cloves</div><div class="ir-n">Garlic</div><div class="ir-x">×</div></div>
<div class="ir"><div class="ir-q">2 tbsp</div><div class="ir-n">Olive oil</div><div class="ir-x">×</div></div>
</div>
<button class="bg" style="width:100%;font-size:12px;padding:8px;">+ Add ingredient</button>
<div class="eye" style="margin:16px 0 8px;">Steps</div>
<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--color-subtle);"><div style="width:24px;height:24px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:500;flex-shrink:0;">1</div><div style="font-size:13px;line-height:1.5;">Preheat oven to 180°C. Halve the tomatoes and place on a baking tray.</div></div>
<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--color-subtle);"><div style="width:24px;height:24px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:500;flex-shrink:0;">2</div><div style="font-size:13px;line-height:1.5;">Drizzle with olive oil, season. Roast for 40 minutes.</div></div>
<div style="display:flex;gap:10px;padding:8px 0;"><div style="width:24px;height:24px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:500;flex-shrink:0;">3</div><div style="font-size:13px;line-height:1.5;">Cook pasta. Toss with roasted tomatoes and garlic.</div></div>
<button class="bg" style="width:100%;font-size:12px;padding:8px;margin-top:8px;">+ Add step</button>
</div>
<!-- Right: tags + preview -->
<div style="width:280px;flex-shrink:0;padding:24px;background:var(--color-surface);overflow-y:auto;">
<div class="eye" style="margin-bottom:8px;">Tags (required)</div>
<div style="font-size:11px;font-weight:500;margin-bottom:4px;">Effort level</div>
<div style="display:flex;gap:4px;margin-bottom:14px;"><span class="tc s">Easy</span><span class="tc">Medium</span><span class="tc">Hard</span></div>
<div style="font-size:11px;font-weight:500;margin-bottom:4px;">Category</div>
<div style="margin-bottom:20px;"><span class="tc">Chicken</span><span class="tc">Fish</span><span class="tc">Beef</span><span class="tc s">Vegetarian</span><span class="tc s">Child-friendly</span><span class="tc">Pasta</span></div>
<div style="border-top:1px solid var(--color-border);padding-top:16px;">
<div class="eye" style="margin-bottom:8px;">Live preview</div>
<div style="background:var(--color-page);border:1px solid var(--color-border);border-radius:var(--radius-lg);overflow:hidden;">
<div style="height:48px;background:var(--green-tint);display:flex;align-items:center;justify-content:center;font-size:18px;">🍝</div>
<div style="padding:10px;"><div style="font-family:var(--font-display);font-size:13px;margin-bottom:3px;">Slow-roasted tomato pasta</div><div style="font-size:9px;color:var(--color-text-muted);margin-bottom:4px;">45 min · 4 servings · 4 ingredients</div><div style="display:flex;gap:3px;"><span class="badge badge-g">Easy</span><span class="badge badge-g">Vegetarian</span><span class="badge badge-y">Child-friendly</span></div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>B3 · Add/edit recipe</h4>
<pre>/* Desktop: 224px sidebar + topbar (breadcrumb + Save) + split content:
* Left (flex:1, page bg): hero upload + name + serves/time/prep (3-col) + ingredients + steps
* Right (280px, surface bg): effort chips + category chips + live preview card
* Form content is NOT in a card — it's directly on the page bg.
* Tags panel (right) uses surface bg as a section differentiator, not as a card.
* Mobile: single scroll, full width. Back + title + Save in topbar.
* Ingredient autocomplete: citext ILIKE from ingredient table. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop</td></tr>
<tr><td>Form area</td><td>flex:1, page bg, 24px padding, border-right</td><td>Contains: hero + name (16px input) + 3-col row + ingredients + steps</td></tr>
<tr><td>Tags panel</td><td>280px, surface bg, 24px padding</td><td>Effort chips + category chips + divider + live preview card</td></tr>
<tr><td>Live preview</td><td>Mini B1 card inside the panel</td><td>Updates as user types. Shows name, time, servings, ingredient count, tags.</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
<div class="llm">
<h2>Implementation Guide — J1 Add a Recipe</h2>
<h3>1. Journey Flow</h3>
<p>B1 (Recipe library) → B3 (Add/edit form) → B1 (Recipe library). Actor: <strong>Planner only</strong>.</p>
<h3>2. Screen B1 — Recipe Library</h3>
<ul>
<li><strong>Mobile:</strong> topbar + 2-col card grid (8px gap, 64px image, no tags on cards).</li>
<li><strong>Desktop:</strong> 224px sidebar + topbar (search input 220px + Add button) + filter chips row + 4-col grid (12px gap, 100px image, tags visible as badge row).</li>
<li><strong>Filter chips:</strong> sourced from the <code>tag</code> table. Active chip = <code>green-tint</code> bg / <code>green-dark</code> text. Inactive chip = <code>border-default</code>. Style: <code>font-size:11px; font-weight:500; padding:5px 14px; border-radius:12px</code>.</li>
<li>Card click → B2 (recipe detail). Add button → B3 (empty form).</li>
</ul>
<h3>3. Screen B3 — Add/Edit Recipe</h3>
<ul>
<li>Single form component with two states: empty (new recipe) vs prefilled (edit existing recipe).</li>
<li><strong>Design rule:</strong> B3 add = B3 edit — build once with two initial states.</li>
<li><strong>Mobile:</strong> single scroll column, full width.</li>
<li><strong>Desktop:</strong> 224px sidebar + topbar (breadcrumb + Save/Cancel buttons) + split content: form left (<code>flex:1</code>, page bg, 24px padding) + tags panel right (280px, surface bg).</li>
</ul>
<p><strong>Form sections:</strong></p>
<ol>
<li>Hero image upload (optional) — dashed border placeholder, click to upload.</li>
<li>Recipe name (required) — 16px font on desktop.</li>
<li>Serves / Cook time / Prep time — 3-col row on desktop, 2-col on mobile (prep time hidden or below).</li>
<li>Ingredients — editable list with autocomplete. Each row: quantity + name + remove button.</li>
<li>Steps — numbered list, optional at save. Each step: circle number + text.</li>
</ol>
<p><strong>Tags (required):</strong></p>
<ul>
<li>Effort level: Easy / Medium / Hard — single-select chip group.</li>
<li>Category: at least 1 required — Chicken, Fish, Beef, Vegetarian, Pasta, etc. Multi-select chips.</li>
</ul>
<p><strong>Desktop right panel:</strong> live preview card (mini B1 card) updates as user types — shows name, time, servings, ingredient count, and selected tags.</p>
<p><strong>Ingredient autocomplete:</strong> <code>citext ILIKE</code> query against the <code>ingredient</code> table.</p>
<h3>4. Data Operations</h3>
<table>
<thead><tr><th>Screen</th><th>Operation</th><th>Tables</th></tr></thead>
<tbody>
<tr><td>B1</td><td><code>SELECT</code> recipes with tags</td><td><code>recipe</code> JOIN <code>recipe_tag</code> + <code>tag</code></td></tr>
<tr><td>B3</td><td><code>INSERT</code> / <code>UPDATE</code> recipe</td><td><code>recipe</code></td></tr>
<tr><td>B3</td><td><code>INSERT</code> / <code>DELETE</code> ingredients</td><td><code>recipe_ingredient</code></td></tr>
<tr><td>B3</td><td><code>INSERT</code> / <code>DELETE</code> steps</td><td><code>recipe_step</code></td></tr>
<tr><td>B3</td><td><code>INSERT</code> / <code>DELETE</code> tags</td><td><code>recipe_tag</code></td></tr>
</tbody>
</table>
<p><strong>Minimum to save:</strong> name + effort tag + at least 1 category tag.</p>
<h3>5. Design Constraints</h3>
<ul>
<li>Tags power the variety algorithm — they are not cosmetic. Ensure tags are always saved correctly.</li>
<li>If entered from a planner day slot, offer to assign the recipe to that day after save.</li>
<li>Steps are optional — recipes without steps can still be planned and cooked from the ingredient list only.</li>
<li>Form content is NOT in a card on desktop — it sits directly on the page background.</li>
<li>The tags panel uses <code>surface</code> bg as a section differentiator, not as a card wrapper.</li>
</ul>
<h3>6. Accessibility</h3>
<ul>
<li>All form inputs must have associated <code>&lt;label&gt;</code> elements.</li>
<li>Ingredient and step lists must support keyboard navigation (add, remove, reorder).</li>
<li>Chip selections (effort level, category tags) must use proper ARIA attributes: <code>role="radiogroup"</code> for single-select effort, <code>role="group"</code> with <code>aria-pressed</code> for multi-select categories.</li>
<li>Live preview region should use <code>aria-live="polite"</code> to announce updates.</li>
</ul>
</div>
</div>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,395 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>J3 — Cook Tonight</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
/* Header */
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;background:var(--green-tint);color:var(--green-dark);}
/* Sections */
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
/* Journey headers */
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-p{background:var(--purple-tint);border:1px solid #CECBF6;}.jh-p .jn{color:var(--purple);}.jh-p p,.jh-p .fl{color:var(--purple-dark);}
.jh-g{background:var(--green-tint);border:1px solid var(--green-light);}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
.jh-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}.jh-y .jn{color:var(--yellow-dark);}.jh-y p,.jh-y .fl{color:var(--yellow-text);}
.jh-o{background:var(--orange-tint);border:1px solid #FBCDA4;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.jh-b{background:var(--blue-tint);border:1px solid var(--blue-light);}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
/* Screen block */
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
/* Preview container */
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
/* Phone frame - 320px */
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
/* Desktop frame - 1040px */
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
/* Shared nav components */
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;}
.mtb-t{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.mi{width:32px;height:32px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);display:flex;align-items:center;justify-content:center;font-size:13px;color:var(--color-text-muted);flex-shrink:0;}.mi.gn{background:var(--green);border-color:var(--green);color:#fff;}
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;}
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;}.mt-i.a .mt-ic{background:var(--green-tint);}.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}.mt-i.a .mt-l{color:var(--green-dark);}
/* Desktop sidebar - 224px per nav-spec */
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:12px;}.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
.dsb-nav{padding:12px 10px;flex:1;}.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;cursor:default;}.dsb-ni.a{background:var(--green-tint);color:var(--green-dark);font-weight:500;}.dsb-nc{font-size:13px;width:18px;text-align:center;}
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
.dtb{padding:12px 24px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;}
.dtb-r{display:flex;align-items:center;gap:8px;}
.dab{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:7px 16px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;}
.dab-b{background:var(--blue);}
/* Shared form */
.fi{width:100%;font-family:var(--font-sans);font-size:14px;padding:10px 12px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-page);color:var(--color-text);outline:none;}
.fl{font-size:12px;font-weight:500;color:var(--color-text);margin-bottom:6px;display:block;}
.fg{margin-bottom:16px;}
.bp{font-family:var(--font-sans);font-size:14px;font-weight:500;padding:12px 24px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;cursor:pointer;width:100%;}
.bg{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:10px 20px;border-radius:var(--radius-md);background:var(--color-subtle);color:var(--color-text-muted);border:1px solid var(--color-border);cursor:pointer;}
/* Tags */
.tc{display:inline-flex;font-size:12px;font-weight:500;padding:6px 12px;border-radius:20px;border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);cursor:pointer;margin:0 4px 4px 0;user-select:none;}.tc.s{background:var(--green-tint);color:var(--green-dark);border-color:var(--green-light);}
.badge{font-size:9px;font-weight:500;padding:2px 6px;border-radius:3px;display:inline-block;}.badge-g{background:var(--green-tint);color:var(--green-dark);}.badge-y{background:var(--yellow-tint);color:var(--yellow-text);}.badge-m{background:var(--color-subtle);color:var(--color-text-muted);}
/* Ingredient rows */
.ir{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--color-subtle);}.ir:last-child{border-bottom:none;}.ir-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);width:55px;flex-shrink:0;text-align:right;}.ir-n{font-size:13px;color:var(--color-text);flex:1;}.ir-x{font-size:14px;color:var(--color-border);cursor:pointer;width:20px;text-align:center;}
/* Checklist */
.ck{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--color-subtle);cursor:pointer;}.ck:last-child{border-bottom:none;}.ck-b{width:22px;height:22px;border-radius:4px;border:2px solid var(--color-border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;}.ck.d .ck-b{background:var(--green);border-color:var(--green);color:#fff;}.ck-c{flex:1;}.ck-n{font-size:14px;color:var(--color-text);}.ck.d .ck-n{text-decoration:line-through;color:var(--color-text-muted);}.ck-s{font-size:10px;color:var(--color-text-muted);}.ck-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);flex-shrink:0;}
/* Suggestion cards */
.sg{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;display:flex;align-items:center;gap:10px;}.sg-r{font-family:var(--font-display);font-size:16px;font-weight:300;color:var(--color-text-muted);width:20px;text-align:center;flex-shrink:0;}.sg-b{flex:1;}.sg-n{font-family:var(--font-display);font-size:13px;font-weight:400;color:var(--color-text);margin-bottom:2px;}.sg-i{font-size:10px;color:var(--color-text-muted);}.sg-w{font-size:9px;color:var(--green-dark);background:var(--green-tint);padding:2px 6px;border-radius:3px;display:inline-block;margin-top:3px;}.sg-p{font-size:11px;font-weight:500;color:var(--green);flex-shrink:0;}
/* Eyebrow labels */
.eye{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
/* Agent table */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* LLM instruction section */
.llm{background:var(--color-page);border:2px solid var(--green);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--green-dark);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ J3 COOK TONIGHT ═══ -->
<div class="jh jh-g" style="background:var(--green-tint);">
<div class="jn" style="color:var(--green-dark);">J3</div>
<div><h2>Cook tonight</h2><p>Recipe detail → cook mode → mark as cooked. Kitchen context.</p><div class="fl">C1 → B2 → B4 → C1 · 16px body, 1.75 line-height, wake lock</div></div>
</div>
<!-- ═══ B2 RECIPE DETAIL ═══ -->
<div class="scr" id="b2">
<div class="scr-head"><h3>Recipe detail</h3><span class="scr-id">B2</span></div>
<div class="scr-desc">V2 Hero header. Desktop: sidebar + full-width hero banner spanning the content area + two-column content below (ingredients left, steps right). The hero fills the horizontal space — it's not a card, it's a page section with a distinct background.</div>
<div class="scr-var"><strong>V2 · Hero header</strong> — banner CTA, 2-col content on desktop</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>18:32</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="background:var(--green-tint);padding:24px 20px 20px;border-bottom:1px solid var(--green-light);">
<div style="font-size:13px;color:var(--green-dark);font-weight:500;margin-bottom:12px;">← Planner</div>
<div style="font-family:var(--font-display);font-size:24px;font-weight:500;letter-spacing:-.02em;color:var(--green-deeper);margin-bottom:6px;">Slow-roasted tomato pasta</div>
<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px;">
<span style="font-size:10px;font-weight:500;padding:3px 8px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">45 min</span>
<span style="font-size:10px;font-weight:500;padding:3px 8px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">Easy</span>
<span style="font-size:10px;font-weight:500;padding:3px 8px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">Serves 4</span>
</div>
<button class="bp" style="background:var(--green-dark);font-size:15px;padding:12px;">🍳 Start cooking</button>
</div>
<div style="padding:16px;">
<div class="eye" style="margin-bottom:8px;">Ingredients</div>
<div style="border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:4px 12px;margin-bottom:16px;">
<div class="ir"><div class="ir-q">500g</div><div class="ir-n">Cherry tomatoes</div></div>
<div class="ir"><div class="ir-q">300g</div><div class="ir-n">Penne pasta</div></div>
<div class="ir"><div class="ir-q">3 cloves</div><div class="ir-n">Garlic</div></div>
<div class="ir"><div class="ir-q">2 tbsp</div><div class="ir-n">Olive oil</div></div>
</div>
<div class="eye" style="margin-bottom:8px;">Steps</div>
<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--color-subtle);"><div style="width:22px;height:22px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;flex-shrink:0;">1</div><div style="font-size:13px;line-height:1.5;">Preheat oven to 180°C. Halve tomatoes.</div></div>
<div style="display:flex;gap:10px;padding:8px 0;border-bottom:1px solid var(--color-subtle);"><div style="width:22px;height:22px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;flex-shrink:0;">2</div><div style="font-size:13px;line-height:1.5;">Drizzle with oil, season, roast 40 min.</div></div>
<div style="display:flex;gap:10px;padding:8px 0;"><div style="width:22px;height:22px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:500;flex-shrink:0;">3</div><div style="font-size:13px;line-height:1.5;">Cook pasta. Toss with roasted tomatoes and garlic.</div></div>
</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk" style="min-height:560px;">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥗</div><div class="dsb-nm">Mealplan</div></div><div class="dsb-sub">Smith household</div></div>
<div class="dsb-nav"><div><div class="dsb-nl">Plan</div><div class="dsb-ni"><span class="dsb-nc">📅</span>Planner</div><div class="dsb-ni a"><span class="dsb-nc">📖</span>Recipes</div><div class="dsb-ni"><span class="dsb-nc">🛒</span>Shopping</div></div></div>
</div>
<div class="dm">
<div class="dtb"><div style="display:flex;align-items:center;gap:8px;"><span style="font-size:13px;color:var(--color-text-muted);">← Recipes</span><span style="color:var(--color-border);">/</span><span class="dtb-t">Slow-roasted tomato pasta</span></div><div class="dtb-r"><button class="bg" style="padding:7px 14px;font-size:12px;">✏️ Edit</button></div></div>
<!-- Hero banner: full content width -->
<div style="background:var(--green-tint);padding:32px;border-bottom:1px solid var(--green-light);display:flex;align-items:center;gap:32px;">
<div style="flex:1;">
<div style="font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;color:var(--green-deeper);margin-bottom:8px;">Slow-roasted tomato pasta</div>
<div style="display:flex;gap:6px;margin-bottom:12px;">
<span style="font-size:11px;font-weight:500;padding:4px 10px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">45 min</span>
<span style="font-size:11px;font-weight:500;padding:4px 10px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">Easy</span>
<span style="font-size:11px;font-weight:500;padding:4px 10px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">Serves 4</span>
<span style="font-size:11px;font-weight:500;padding:4px 10px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">Vegetarian</span>
<span style="font-size:11px;font-weight:500;padding:4px 10px;border-radius:12px;background:rgba(255,255,255,.6);color:var(--green-dark);">Child-friendly</span>
</div>
<div style="font-size:13px;color:var(--green-dark);line-height:1.5;max-width:400px;">Roasting the tomatoes concentrates their sweetness — great for the kids and easy to batch cook.</div>
</div>
<button class="bp" style="width:auto;padding:14px 28px;font-size:15px;background:var(--green-dark);flex-shrink:0;">🍳 Start cooking</button>
</div>
<!-- 2-col: ingredients left, steps right -->
<div style="flex:1;display:flex;overflow:hidden;">
<div style="flex:1;padding:24px;border-right:1px solid var(--color-border);overflow-y:auto;">
<div class="eye" style="margin-bottom:10px;">Ingredients · 4 items</div>
<div class="ir"><div class="ir-q">500g</div><div class="ir-n">Cherry tomatoes</div></div>
<div class="ir"><div class="ir-q">300g</div><div class="ir-n">Penne pasta</div></div>
<div class="ir"><div class="ir-q">3 cloves</div><div class="ir-n">Garlic</div></div>
<div class="ir"><div class="ir-q">2 tbsp</div><div class="ir-n">Olive oil</div></div>
</div>
<div style="flex:1;padding:24px;overflow-y:auto;">
<div class="eye" style="margin-bottom:10px;">Steps · 3</div>
<div style="display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--color-subtle);"><div style="width:28px;height:28px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">1</div><div style="font-size:14px;line-height:1.6;">Preheat oven to 180°C. Halve the cherry tomatoes and place cut-side up on a large baking tray.</div></div>
<div style="display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--color-subtle);"><div style="width:28px;height:28px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">2</div><div style="font-size:14px;line-height:1.6;">Drizzle generously with olive oil, season with salt and pepper. Roast for 40 minutes until caramelized.</div></div>
<div style="display:flex;gap:12px;padding:10px 0;"><div style="width:28px;height:28px;border-radius:50%;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">3</div><div style="font-size:14px;line-height:1.6;">Cook pasta according to package. Toss with roasted tomatoes and minced garlic. Serve with fresh basil.</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>B2 · Recipe detail</h4>
<pre>/* Desktop: sidebar + topbar (breadcrumb + Edit button) + hero banner (full width, green-tint) + 2-col below.
* Hero: name Fraunces 28px + tag pills + description + Cook button on the right.
* Below hero: ingredients (left, border-right) + steps (right). Both panels scroll independently.
* Hero with image: hero_image_url as bg, 40% dark overlay, text in white.
* Mobile: hero banner → stacked ingredients → steps.
* "Start cooking" → B4. "Edit" → B3 in edit mode. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop hero</td></tr>
<tr><td>Layout</td><td>Full content width, green-tint bg, 32px padding</td><td>Flex: info left (flex:1) + Cook button right (flex-shrink:0)</td></tr>
<tr><td>Name</td><td>Fraunces 28px/500, green-deeper</td><td>With image: #fff on dark overlay</td></tr>
<tr class="grp"><td colspan="3">Desktop content below hero</td></tr>
<tr><td>Ingredients</td><td>flex:1, 24px padding, border-right</td><td>ir-row component. Scrolls independently.</td></tr>
<tr><td>Steps</td><td>flex:1, 24px padding</td><td>Numbered circles (28px) + 14px body text, 1.6 line-height.</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ B4 COOK MODE ═══ -->
<div class="scr" id="b4">
<div class="scr-head"><h3>Cook mode</h3><span class="scr-id">B4</span></div>
<div class="scr-desc">V1 Centered step. Full-screen on ALL breakpoints — intentionally no sidebar, no tabs, no nav chrome. Kitchen context doesn't change with screen size. Only the text max-width scales (260→400px). The entire screen body is the tap target.</div>
<div class="scr-var"><strong>V1 · Centered step</strong> — same layout everywhere. No desktop sidebar. Tap anywhere.</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · Step 2 of 3</div>
<div class="phone">
<div class="pst"><b>18:45</b><span>●●● WiFi 🔋</span></div>
<div class="pb" style="cursor:pointer;">
<div style="padding:10px 16px;display:flex;justify-content:space-between;align-items:center;">
<div style="font-size:12px;color:var(--color-error);font-weight:500;">✕ Exit</div>
<div style="font-size:11px;color:var(--color-text-muted);">Step 2 of 3</div>
<div style="width:40px;"></div>
</div>
<div style="padding:0 16px;"><div style="height:4px;background:var(--color-subtle);border-radius:2px;overflow:hidden;"><div style="width:66%;height:100%;background:var(--green);border-radius:2px;"></div></div></div>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:32px 24px;text-align:center;">
<div style="font-family:var(--font-display);font-size:56px;font-weight:300;color:var(--green-light);margin-bottom:16px;line-height:1;">2</div>
<div style="font-size:16px;line-height:1.75;color:var(--color-text);max-width:260px;">Drizzle with olive oil, season generously with salt and pepper. Roast for 40 minutes until caramelized.</div>
</div>
<div style="padding:16px;text-align:center;font-size:12px;color:var(--color-text-muted);">Tap anywhere for next step →</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · SAME layout, wider text</div>
<div class="desk" style="min-height:480px;flex-direction:column;">
<div style="padding:14px 24px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--color-border);">
<div style="font-size:13px;color:var(--color-error);font-weight:500;cursor:pointer;">✕ Exit</div>
<div style="font-size:12px;color:var(--color-text-muted);">Step 2 of 3</div>
<div style="width:60px;"></div>
</div>
<div style="padding:0 24px;margin-top:4px;"><div style="height:4px;background:var(--color-subtle);border-radius:2px;overflow:hidden;"><div style="width:66%;height:100%;background:var(--green);border-radius:2px;"></div></div></div>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;text-align:center;cursor:pointer;">
<div style="font-family:var(--font-display);font-size:72px;font-weight:300;color:var(--green-light);margin-bottom:24px;line-height:1;">2</div>
<div style="font-size:16px;line-height:1.75;color:var(--color-text);max-width:400px;">Drizzle with olive oil, season generously with salt and pepper. Roast for 40 minutes until caramelized.</div>
</div>
<div style="padding:20px;text-align:center;font-size:13px;color:var(--color-text-muted);">Tap anywhere for next step →</div>
</div>
</div>
</div>
<div class="agent">
<h4>B4 · Cook mode · KITCHEN CRITICAL</h4>
<pre>/* FULL SCREEN on all breakpoints. NO sidebar. NO tabs. NO nav chrome.
* Body text: EXACTLY 16px, line-height 1.75. NON-NEGOTIABLE.
* Max text width: 260px mobile, 320px tablet, 400px desktop.
* Step number: Fraunces 56px mobile, 72px desktop. Green-light.
* TAP ANYWHERE to advance. Wake lock on enter. Exit = top-left error red.
* Final step: "Done — mark as cooked" → cooking_log INSERT → C1.
* This is an EXCEPTION to nav-spec: no sidebar, no breadcrumbs, no tabs. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Layout — IDENTICAL all breakpoints</td></tr>
<tr><td>Body text</td><td>16px, line-height 1.75</td><td>NON-NEGOTIABLE</td></tr>
<tr><td>Max text width</td><td>260px mobile / 320px tablet / 400px desktop</td><td>Only difference between breakpoints</td></tr>
<tr><td>Step number</td><td>Fraunces 56px mobile / 72px desktop, weight 300, green-light</td><td>Visible from arm's length</td></tr>
<tr><td>Tap target</td><td>Entire body area</td><td>onclick → next step</td></tr>
<tr><td>Wake lock</td><td>navigator.wakeLock.request('screen')</td><td>On enter, release on exit/done</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ LLM INSTRUCTIONS ═══ -->
<div class="llm">
<h2>LLM Implementation Instructions — J3 Cook Tonight</h2>
<p>This section provides structured guidance for code-generating LLMs implementing the J3 journey. Follow these rules exactly.</p>
<h3>1. Journey Flow</h3>
<p><strong>C1</strong> (today highlight) → <strong>B2</strong> (recipe detail) → <strong>B4</strong> (cook mode) → <strong>C1</strong></p>
<ul>
<li><strong>Actor:</strong> Planner</li>
<li><strong>Context:</strong> Mobile, hands busy, kitchen environment</li>
<li>The user taps today's meal on the planner (C1), views the recipe (B2), enters cook mode (B4), steps through instructions, marks as cooked, and returns to the planner (C1).</li>
</ul>
<h3>2. Screen B2 — Recipe Detail</h3>
<h3>Mobile layout</h3>
<ul>
<li>Green-tint hero banner at top: back link ("← Planner"), title in Fraunces 24px, pill tags (time, difficulty, servings), and a full-width "Start cooking" button.</li>
<li>Below the hero: stacked ingredients section, then steps section.</li>
</ul>
<h3>Desktop layout</h3>
<ul>
<li>Sidebar (224px) + topbar with breadcrumb ("← Recipes / Recipe Name") and Edit button.</li>
<li>Full-width hero banner: green-tint background, 32px padding. Flex layout with recipe info left (<code>flex:1</code>) and Cook button right (<code>flex-shrink:0</code>).</li>
<li>Below hero: 2-column layout. Ingredients left (<code>flex:1</code>, <code>border-right</code>) + steps right (<code>flex:1</code>). Both panels scroll independently.</li>
</ul>
<h3>Hero variants</h3>
<ul>
<li><strong>With image:</strong> <code>hero_image_url</code> as background, 40% dark overlay, all text rendered in white.</li>
<li><strong>Without image:</strong> green-tint background, dark text (green-deeper for title).</li>
</ul>
<h3>Component details</h3>
<ul>
<li><strong>Ingredients:</strong> Read-only <code>.ir</code> rows (quantity + name). Quantities are scaled to the saved serving count for the planned meal.</li>
<li><strong>Steps:</strong> Numbered circles (28px diameter on desktop, 22px mobile) + step text (14px, line-height 1.6 on desktop; 13px, line-height 1.5 on mobile).</li>
</ul>
<h3>Navigation</h3>
<ul>
<li>"Start cooking" button → navigates to <strong>B4</strong> (cook mode).</li>
<li>"Edit" button → navigates to <strong>B3</strong> (recipe form, prefilled with current recipe data).</li>
</ul>
<h3>3. Screen B4 — Cook Mode (KITCHEN CRITICAL)</h3>
<p>This screen is the single exception to all responsive and navigation patterns in the app.</p>
<h3>Layout rules</h3>
<ul>
<li><strong>IDENTICAL LAYOUT on ALL breakpoints</strong> — this is the ONLY screen that ignores responsive patterns.</li>
<li><strong>NO sidebar, NO tabs, NO navigation chrome on ANY breakpoint.</strong> This is an explicit exception to the nav-spec.</li>
</ul>
<h3>Topbar</h3>
<ul>
<li>Three-column flex: Exit button (left, error red <code>var(--color-error)</code>) + progress indicator (center, "Step N of M") + empty spacer (right).</li>
</ul>
<h3>Progress bar</h3>
<ul>
<li>Height: 4px. Track: <code>var(--color-subtle)</code>. Fill: <code>var(--green)</code>. Border-radius: 2px.</li>
<li>Width percentage: <code>(currentStep / totalSteps) * 100%</code>.</li>
</ul>
<h3>Body</h3>
<ul>
<li>Centered step number: Fraunces, weight 300, <code>var(--green-light)</code>. Size: 56px (mobile) / 72px (desktop).</li>
<li>Step text: <strong>EXACTLY 16px, line-height 1.75 — NON-NEGOTIABLE.</strong> This is a readability requirement for kitchen use with dirty hands.</li>
<li>Max text width: 260px (mobile) / 320px (tablet) / 400px (desktop).</li>
</ul>
<h3>Interaction</h3>
<ul>
<li><strong>TAP ANYWHERE to advance:</strong> The entire body area is the tap target. No fine motor precision required.</li>
<li>On the final step, the tap target label changes to "Done — mark as cooked".</li>
<li>Tapping on the final step triggers: <code>cooking_log INSERT</code> with <code>recipe_id</code> and <code>cooked_date</code> set to today's date, then navigates back to <strong>C1</strong>.</li>
</ul>
<h3>Wake lock</h3>
<ul>
<li>On entering B4: call <code>navigator.wakeLock.request('screen')</code> to prevent the screen from sleeping.</li>
<li>On exiting B4 (via Exit button or "Done"): release the wake lock.</li>
</ul>
<h3>4. Critical Feedback Loop</h3>
<p>The <code>cooking_log</code> table is the data source for the variety/repetition algorithm used in J2 (meal suggestions). Meals cooked more recently are weighted more heavily in the repetition filter, causing the algorithm to deprioritize them in future suggestions. This means J3's "mark as cooked" action directly makes J2's suggestions smarter over time.</p>
<h3>5. Data Operations</h3>
<table>
<thead><tr><th>Screen</th><th>Operation</th><th>Tables</th></tr></thead>
<tbody>
<tr><td>B2</td><td>READ</td><td><code>recipe</code>, <code>recipe_ingredient</code>, <code>ingredient</code>, <code>recipe_step</code></td></tr>
<tr><td>B4</td><td>WRITE</td><td><code>cooking_log</code> INSERT (<code>recipe_id</code>, <code>cooked_date</code>)</td></tr>
</tbody>
</table>
<h3>6. Accessibility</h3>
<ul>
<li>B4's tap target is the entire screen body — accessible with one finger, no fine motor precision needed. This is intentional for kitchen use where hands may be wet or dirty.</li>
<li>The wake lock prevents the screen from sleeping mid-recipe, eliminating the need to unlock the device with messy hands.</li>
<li>Step text at 16px with 1.75 line-height ensures readability at arm's length.</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,295 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>J4 — Adapt on the Fly</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
/* Header */
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;background:var(--green-tint);color:var(--green-dark);}
/* Sections */
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
/* Journey headers */
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-p{background:var(--purple-tint);border:1px solid #CECBF6;}.jh-p .jn{color:var(--purple);}.jh-p p,.jh-p .fl{color:var(--purple-dark);}
.jh-g{background:var(--green-tint);border:1px solid var(--green-light);}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
.jh-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}.jh-y .jn{color:var(--yellow-dark);}.jh-y p,.jh-y .fl{color:var(--yellow-text);}
.jh-o{background:var(--orange-tint);border:1px solid #FBCDA4;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.jh-b{background:var(--blue-tint);border:1px solid var(--blue-light);}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
/* Screen block */
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
/* Preview container */
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
/* Phone frame - 320px */
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
/* Desktop frame - 1040px */
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
/* Shared nav components */
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;}
.mtb-t{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.mi{width:32px;height:32px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);display:flex;align-items:center;justify-content:center;font-size:13px;color:var(--color-text-muted);flex-shrink:0;}.mi.gn{background:var(--green);border-color:var(--green);color:#fff;}
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;}
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;}.mt-i.a .mt-ic{background:var(--green-tint);}.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}.mt-i.a .mt-l{color:var(--green-dark);}
/* Desktop sidebar - 224px per nav-spec */
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:12px;}.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
.dsb-nav{padding:12px 10px;flex:1;}.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;cursor:default;}.dsb-ni.a{background:var(--green-tint);color:var(--green-dark);font-weight:500;}.dsb-nc{font-size:13px;width:18px;text-align:center;}
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
.dtb{padding:12px 24px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;}
.dtb-r{display:flex;align-items:center;gap:8px;}
.dab{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:7px 16px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;}
.dab-b{background:var(--blue);}
/* Shared form */
.fi{width:100%;font-family:var(--font-sans);font-size:14px;padding:10px 12px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-page);color:var(--color-text);outline:none;}
.fl{font-size:12px;font-weight:500;color:var(--color-text);margin-bottom:6px;display:block;}
.fg{margin-bottom:16px;}
.bp{font-family:var(--font-sans);font-size:14px;font-weight:500;padding:12px 24px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;cursor:pointer;width:100%;}
.bg{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:10px 20px;border-radius:var(--radius-md);background:var(--color-subtle);color:var(--color-text-muted);border:1px solid var(--color-border);cursor:pointer;}
/* Tags */
.tc{display:inline-flex;font-size:12px;font-weight:500;padding:6px 12px;border-radius:20px;border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);cursor:pointer;margin:0 4px 4px 0;user-select:none;}.tc.s{background:var(--green-tint);color:var(--green-dark);border-color:var(--green-light);}
.badge{font-size:9px;font-weight:500;padding:2px 6px;border-radius:3px;display:inline-block;}.badge-g{background:var(--green-tint);color:var(--green-dark);}.badge-y{background:var(--yellow-tint);color:var(--yellow-text);}.badge-m{background:var(--color-subtle);color:var(--color-text-muted);}
/* Ingredient rows */
.ir{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--color-subtle);}.ir:last-child{border-bottom:none;}.ir-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);width:55px;flex-shrink:0;text-align:right;}.ir-n{font-size:13px;color:var(--color-text);flex:1;}.ir-x{font-size:14px;color:var(--color-border);cursor:pointer;width:20px;text-align:center;}
/* Checklist */
.ck{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--color-subtle);cursor:pointer;}.ck:last-child{border-bottom:none;}.ck-b{width:22px;height:22px;border-radius:4px;border:2px solid var(--color-border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;}.ck.d .ck-b{background:var(--green);border-color:var(--green);color:#fff;}.ck-c{flex:1;}.ck-n{font-size:14px;color:var(--color-text);}.ck.d .ck-n{text-decoration:line-through;color:var(--color-text-muted);}.ck-s{font-size:10px;color:var(--color-text-muted);}.ck-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);flex-shrink:0;}
/* Suggestion cards */
.sg{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;display:flex;align-items:center;gap:10px;}.sg-r{font-family:var(--font-display);font-size:16px;font-weight:300;color:var(--color-text-muted);width:20px;text-align:center;flex-shrink:0;}.sg-b{flex:1;}.sg-n{font-family:var(--font-display);font-size:13px;font-weight:400;color:var(--color-text);margin-bottom:2px;}.sg-i{font-size:10px;color:var(--color-text-muted);}.sg-w{font-size:9px;color:var(--green-dark);background:var(--green-tint);padding:2px 6px;border-radius:3px;display:inline-block;margin-top:3px;}.sg-p{font-size:11px;font-weight:500;color:var(--green);flex-shrink:0;}
/* Eyebrow labels */
.eye{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
/* Agent table */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* LLM instruction box */
.llm{background:var(--color-page);border:2px solid var(--green-light);border-radius:var(--radius-lg);padding:28px 24px;margin-top:64px;}
.llm h3{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;color:var(--green-dark);margin-bottom:16px;}
.llm h4{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--green-dark);margin-top:20px;margin-bottom:8px;}
.llm p,.llm li{font-size:12px;color:var(--color-text-muted);line-height:1.7;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--green-tint);color:var(--green-dark);padding:1px 5px;border-radius:3px;}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<!-- ═══ J4 SWAP ═══ -->
<div class="jh jh-o">
<div class="jn">J4</div>
<div><h2>Adapt on the fly</h2><p>Mid-week swap in ≤ 3 taps. Action sheet → pick replacement → done.</p><div class="fl">C1 → action sheet → C2 swap → C1 · Easiest first</div></div>
</div>
<!-- ═══ SWAP TRIGGER ═══ -->
<div class="scr" id="swap-trigger">
<div class="scr-head"><h3>Swap trigger</h3><span class="scr-id">C1 overlay</span></div>
<div class="scr-desc">Mobile: tap meal → bottom action sheet (Swap / Cook / View / Cancel). Desktop: no action sheet needed — the C1 detail panel already has a "Swap meal" button. Clicking it transitions the detail panel content to show swap suggestions inline.</div>
<div class="scr-var"><strong>V4 · Action sheet</strong> (mobile) · Detail panel button (desktop)</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · Action sheet</div>
<div class="phone">
<div class="pst"><b>17:15</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div class="mtb" style="opacity:.4;"><div class="mtb-t" style="font-size:16px;">This week</div></div>
<div style="padding:8px 12px;opacity:.4;">
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px;margin-bottom:6px;font-family:var(--font-display);font-size:12px;">Mon · Chicken stir-fry</div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px;font-family:var(--font-display);font-size:12px;">Tue · Tomato pasta</div>
</div>
<div style="flex:1;display:flex;flex-direction:column;justify-content:flex-end;">
<div style="background:var(--color-page);border-top:1px solid var(--color-border);border-radius:var(--radius-xl) var(--radius-xl) 0 0;padding:16px;box-shadow:0 -4px 20px rgba(0,0,0,.12);">
<div style="width:32px;height:4px;border-radius:2px;background:var(--color-border);margin:0 auto 12px;"></div>
<div style="font-family:var(--font-display);font-size:15px;font-weight:500;margin-bottom:4px;">Tuesday — Tomato pasta</div>
<div style="font-size:11px;color:var(--color-text-muted);margin-bottom:12px;">45 min · Easy · Vegetarian</div>
<div style="display:flex;flex-direction:column;gap:6px;">
<div style="padding:12px;border-radius:var(--radius-lg);background:var(--orange-tint);border:1px solid #FBCDA4;font-size:13px;font-weight:500;color:var(--orange-dark);text-align:center;">↻ Swap this meal</div>
<div style="padding:12px;border-radius:var(--radius-lg);background:var(--green-tint);border:1px solid var(--green-light);font-size:13px;font-weight:500;color:var(--green-dark);text-align:center;">🍳 Cook now</div>
<div style="padding:12px;border-radius:var(--radius-lg);background:var(--color-subtle);border:1px solid var(--color-border);font-size:13px;font-weight:500;color:var(--color-text-muted);text-align:center;">👁 View recipe</div>
<div style="padding:12px;font-size:13px;color:var(--color-text-muted);text-align:center;">Cancel</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>Swap trigger</h4>
<pre>/* Mobile: tap meal card → bottom action sheet. Planner dims to 40%.
* Sheet: drag handle + meal name + meta + 4 action buttons stacked.
* Swap = orange-tint. Cook = green-tint. View = subtle. Cancel = no bg.
* Desktop: no action sheet. C1 detail panel has "Swap meal" ghost button (per planner-spec).
* Clicking "Swap meal" transitions the detail panel to show swap suggestions inline.
* Tap count: Mobile 3 (card → Swap → Pick). Desktop 2 (Swap → Pick). */</pre>
</div>
</div>
<!-- ═══ C2 SWAP CONTEXT ═══ -->
<div class="scr" id="swap-context">
<div class="scr-head"><h3>C2 in swap context</h3><span class="scr-id">C2 swap</span></div>
<div class="scr-desc">Mobile: bottom sheet over C1 with "Replacing" banner + easiest-first suggestions. Desktop: swap suggestions render inline in the C1 detail panel (replacing the meal detail) — the calendar grid stays visible alongside. No page navigation needed.</div>
<div class="scr-var"><strong>V1 · Quick swap sheet</strong> (mobile) · Inline detail panel (desktop)</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · Swap sheet</div>
<div class="phone">
<div class="pst"><b>17:16</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="opacity:.3;padding:10px 16px;border-bottom:1px solid var(--color-border);font-family:var(--font-display);font-size:16px;font-weight:500;">This week</div>
<div style="flex:1;display:flex;flex-direction:column;justify-content:flex-end;">
<div style="background:var(--color-page);border-top:1px solid var(--color-border);border-radius:var(--radius-xl) var(--radius-xl) 0 0;padding:16px;box-shadow:0 -4px 20px rgba(0,0,0,.12);">
<div style="width:32px;height:4px;border-radius:2px;background:var(--color-border);margin:0 auto 10px;"></div>
<div style="background:var(--orange-tint);border:1px solid #FBCDA4;border-radius:var(--radius-lg);padding:10px 12px;margin-bottom:14px;">
<div class="eye" style="color:var(--orange-dark);margin-bottom:4px;">Replacing Tuesday's meal</div>
<div style="font-family:var(--font-display);font-size:14px;text-decoration:line-through;opacity:.6;">Tomato pasta · 45 min · Easy</div>
</div>
<div class="eye" style="margin-bottom:6px;">Swap to (easiest first)</div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px 12px;margin-bottom:6px;display:flex;align-items:center;gap:8px;">
<div style="flex:1;"><div style="font-family:var(--font-display);font-size:13px;">Quick carbonara</div><div style="font-size:9px;color:var(--color-text-muted);">20 min · Easy · Pasta</div></div>
<div style="font-size:11px;color:var(--green);font-weight:500;">Pick</div>
</div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px 12px;margin-bottom:6px;display:flex;align-items:center;gap:8px;">
<div style="flex:1;"><div style="font-family:var(--font-display);font-size:13px;">Chicken stir-fry</div><div style="font-size:9px;color:var(--color-text-muted);">25 min · Easy</div><div style="font-size:9px;color:var(--yellow-text);">⚠ Already on Mon</div></div>
<div style="font-size:11px;color:var(--green);font-weight:500;">Pick</div>
</div>
<div style="background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:10px 12px;margin-bottom:6px;display:flex;align-items:center;gap:8px;">
<div style="flex:1;"><div style="font-family:var(--font-display);font-size:13px;">Mushroom risotto</div><div style="font-size:9px;color:var(--color-text-muted);">50 min · Medium · Veggie</div></div>
<div style="font-size:11px;color:var(--green);font-weight:500;">Pick</div>
</div>
<div style="text-align:center;padding:8px 0;font-size:11px;color:var(--color-text-muted);">Cancel</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>C2 swap context</h4>
<pre>/* Mobile: bottom sheet over dimmed C1. "Replacing" banner + suggestion list.
* Sorted EASIEST FIRST (effort ASC, cook_time ASC) — different from J2.
* "Pick" → UPDATE week_plan_slot. Dismiss sheet. No confirmation dialog (undo toast instead).
* Desktop: detail panel (280px) transitions in-place. Calendar grid stays visible.
* Replacing header: orange-tint, old meal struck through.
* Suggestion cards: compact, fitting panel width. Name + meta + "Pick" link.
* Tap count: Mobile 3. Desktop 2 (faster — no action sheet intermediary). */</pre>
</div>
</div>
<!-- ═══ LLM INSTRUCTIONS ═══ -->
<div class="llm">
<h3>LLM Implementation Instructions — J4 Adapt on the Fly</h3>
<h4>1. Journey Flow</h4>
<p>C1 → action sheet (mobile) or detail panel button (desktop) → swap suggestions → pick → C1.
Actor: <strong>Planner</strong>. Frequency: 1-2x/week. Urgency: <strong>HIGH</strong>.</p>
<h4>2. Constraint: 3 taps maximum</h4>
<p>From "Swap" to updated plan in no more than 3 taps.</p>
<ul>
<li><strong>Mobile: 3 taps</strong> — card → Swap → Pick</li>
<li><strong>Desktop: 2 taps</strong> — Swap → Pick (no action sheet intermediary)</li>
</ul>
<h4>3. Mobile: Action Sheet</h4>
<ul>
<li>Bottom sheet pulls up on meal tap; background dims to <strong>40% opacity</strong>.</li>
<li>Drag handle: 32px wide, 4px height, <code>var(--color-border)</code> background.</li>
<li>Meal title in 15px display font + metadata in 11px muted text.</li>
<li>4 stacked buttons:
<ol>
<li><strong>"Swap this meal"</strong><code>orange-tint</code> bg / <code>orange-dark</code> text</li>
<li><strong>"Cook now"</strong><code>green-tint</code> bg / <code>green-dark</code> text</li>
<li><strong>"View recipe"</strong><code>subtle</code> bg / <code>muted</code> text</li>
<li><strong>"Cancel"</strong> — no background, muted text</li>
</ol>
</li>
</ul>
<h4>4. Mobile: Swap Suggestions (Bottom Sheet)</h4>
<ul>
<li>"Replacing" banner: <code>orange-tint</code> background, old meal name struck through.</li>
<li>"Swap to (easiest first)" eyebrow label above suggestion list.</li>
<li>Compact suggestion cards: recipe name + time/effort/tag + "Pick" link on the right.</li>
<li><strong>Sorted EASIEST FIRST</strong><code>effort ASC, cook_time ASC</code>. This is DIFFERENT from C2 in J2, which sorts by variety score.</li>
<li>"Pick" action: <code>UPDATE week_plan_slot</code> with new <code>recipe_id</code> → dismiss sheet → show <strong>undo toast</strong> (NOT a confirmation dialog).</li>
</ul>
<h4>5. Desktop: Inline Panel</h4>
<ul>
<li>No action sheet needed — the C1 detail panel (280px wide) already has a "Swap meal" ghost button.</li>
<li>Clicking the button transitions the detail panel content <strong>in-place</strong> to show swap suggestions.</li>
<li>Calendar grid stays visible alongside the panel at all times.</li>
<li>Same sorting (<code>effort ASC, cook_time ASC</code>) and "Replacing" header as mobile.</li>
</ul>
<h4>6. Why Easiest First</h4>
<p>Mid-week swaps typically happen because the original plan was too ambitious. Sorting by effort makes the fastest, lowest-effort options most visible, matching the user's intent to simplify.</p>
<h4>7. Data Operations</h4>
<ul>
<li><strong>Writes:</strong> <code>week_plan_slot UPDATE</code> — sets new <code>recipe_id</code> on the slot.</li>
<li>Swap is logged: both the original meal (marked as not cooked) and the replacement are recorded.</li>
<li>Original uncooked meal remains in the recipe library for future weeks.</li>
<li>Variety score recalculates immediately after swap.</li>
</ul>
<h4>8. Design Constraints</h4>
<ul>
<li><strong>Speed over deliberation</strong> — undo toast instead of confirmation dialog. The user is in a hurry mid-week.</li>
<li>Orange accent color for swap context: <code>orange-tint</code> background, <code>orange-dark</code> text on banners and primary action.</li>
<li>Variety filter still applies to suggestions (duplicates get a warning), just sorted differently than J2.</li>
</ul>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,389 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Recipe App — J5 Shopping List Journey Spec</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
/* Header */
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;background:var(--green-tint);color:var(--green-dark);}
/* Sections */
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
/* Journey headers */
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-p{background:var(--purple-tint);border:1px solid #CECBF6;}.jh-p .jn{color:var(--purple);}.jh-p p,.jh-p .fl{color:var(--purple-dark);}
.jh-g{background:var(--green-tint);border:1px solid var(--green-light);}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
.jh-y{background:var(--yellow-tint);border:1px solid var(--yellow-light);}.jh-y .jn{color:var(--yellow-dark);}.jh-y p,.jh-y .fl{color:var(--yellow-text);}
.jh-o{background:var(--orange-tint);border:1px solid #FBCDA4;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
.jh-b{background:var(--blue-tint);border:1px solid var(--blue-light);}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
/* Screen block */
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
/* Preview container */
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
/* Phone frame - 320px */
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
/* Desktop frame - 1040px */
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
/* Shared nav components */
.mtb{padding:10px 16px;background:var(--color-page);border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;}
.mtb-t{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.mi{width:32px;height:32px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-surface);display:flex;align-items:center;justify-content:center;font-size:13px;color:var(--color-text-muted);flex-shrink:0;}.mi.gn{background:var(--green);border-color:var(--green);color:#fff;}
.mbt{border-top:1px solid var(--color-border);background:var(--color-surface);padding:8px 16px 28px;display:flex;justify-content:space-around;}
.mt-i{display:flex;flex-direction:column;align-items:center;gap:2px;}.mt-ic{width:20px;height:20px;border-radius:4px;background:var(--color-subtle);display:flex;align-items:center;justify-content:center;font-size:11px;}.mt-i.a .mt-ic{background:var(--green-tint);}.mt-l{font-size:9px;font-weight:500;color:var(--color-text-muted);}.mt-i.a .mt-l{color:var(--green-dark);}
/* Desktop sidebar - 224px per nav-spec */
.dsb{width:224px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);display:flex;flex-direction:column;}
.dsb-logo{padding:20px 16px 16px;border-bottom:1px solid var(--color-border);}
.dsb-lm{display:flex;align-items:center;gap:8px;margin-bottom:2px;}.dsb-ic{width:24px;height:24px;border-radius:5px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:12px;}.dsb-nm{font-family:var(--font-display);font-size:16px;font-weight:500;letter-spacing:-.02em;}.dsb-sub{font-size:10px;color:var(--color-text-muted);padding-left:32px;}
.dsb-nav{padding:12px 10px;flex:1;}.dsb-nl{font-size:8px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:var(--color-text-muted);padding:0 8px;margin-bottom:4px;}.dsb-ni{display:flex;align-items:center;gap:8px;padding:7px 8px;border-radius:var(--radius-md);font-size:13px;color:var(--color-text-muted);margin-bottom:2px;cursor:default;}.dsb-ni.a{background:var(--green-tint);color:var(--green-dark);font-weight:500;}.dsb-nc{font-size:13px;width:18px;text-align:center;}
.dm{flex:1;display:flex;flex-direction:column;min-width:0;}
.dtb{padding:12px 24px;border-bottom:1px solid var(--color-border);display:flex;justify-content:space-between;align-items:center;flex-shrink:0;}
.dtb-t{font-family:var(--font-display);font-size:18px;font-weight:500;letter-spacing:-.02em;}
.dtb-r{display:flex;align-items:center;gap:8px;}
.dab{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:7px 16px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;}
.dab-b{background:var(--blue);}
/* Shared form */
.fi{width:100%;font-family:var(--font-sans);font-size:14px;padding:10px 12px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-page);color:var(--color-text);outline:none;}
.fl{font-size:12px;font-weight:500;color:var(--color-text);margin-bottom:6px;display:block;}
.fg{margin-bottom:16px;}
.bp{font-family:var(--font-sans);font-size:14px;font-weight:500;padding:12px 24px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;cursor:pointer;width:100%;}
.bg{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:10px 20px;border-radius:var(--radius-md);background:var(--color-subtle);color:var(--color-text-muted);border:1px solid var(--color-border);cursor:pointer;}
/* Tags */
.tc{display:inline-flex;font-size:12px;font-weight:500;padding:6px 12px;border-radius:20px;border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);cursor:pointer;margin:0 4px 4px 0;user-select:none;}.tc.s{background:var(--green-tint);color:var(--green-dark);border-color:var(--green-light);}
.badge{font-size:9px;font-weight:500;padding:2px 6px;border-radius:3px;display:inline-block;}.badge-g{background:var(--green-tint);color:var(--green-dark);}.badge-y{background:var(--yellow-tint);color:var(--yellow-text);}.badge-m{background:var(--color-subtle);color:var(--color-text-muted);}
/* Ingredient rows */
.ir{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--color-subtle);}.ir:last-child{border-bottom:none;}.ir-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);width:55px;flex-shrink:0;text-align:right;}.ir-n{font-size:13px;color:var(--color-text);flex:1;}.ir-x{font-size:14px;color:var(--color-border);cursor:pointer;width:20px;text-align:center;}
/* Checklist */
.ck{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--color-subtle);cursor:pointer;}.ck:last-child{border-bottom:none;}.ck-b{width:22px;height:22px;border-radius:4px;border:2px solid var(--color-border);flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:11px;}.ck.d .ck-b{background:var(--green);border-color:var(--green);color:#fff;}.ck-c{flex:1;}.ck-n{font-size:14px;color:var(--color-text);}.ck.d .ck-n{text-decoration:line-through;color:var(--color-text-muted);}.ck-s{font-size:10px;color:var(--color-text-muted);}.ck-q{font-family:var(--font-mono);font-size:12px;color:var(--color-text-muted);flex-shrink:0;}
/* Suggestion cards */
.sg{background:var(--color-surface);border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:12px;margin-bottom:8px;display:flex;align-items:center;gap:10px;}.sg-r{font-family:var(--font-display);font-size:16px;font-weight:300;color:var(--color-text-muted);width:20px;text-align:center;flex-shrink:0;}.sg-b{flex:1;}.sg-n{font-family:var(--font-display);font-size:13px;font-weight:400;color:var(--color-text);margin-bottom:2px;}.sg-i{font-size:10px;color:var(--color-text-muted);}.sg-w{font-size:9px;color:var(--green-dark);background:var(--green-tint);padding:2px 6px;border-radius:3px;display:inline-block;margin-top:3px;}.sg-p{font-size:11px;font-weight:500;color:var(--green);flex-shrink:0;}
/* Eyebrow labels */
.eye{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
/* Agent table (inline) */
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* LLM instruction section */
.llm{background:var(--color-text);color:#E8E8E2;border-radius:var(--radius-lg);padding:36px 44px;margin-top:80px;}
.llm h2{font-size:10px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#6B6A63;margin-bottom:6px;}
.llm > p{font-size:13px;color:#9A9990;margin-bottom:28px;line-height:1.6;max-width:640px;}
.llm h3{font-size:9px;font-weight:500;letter-spacing:.09em;text-transform:uppercase;color:#6B6A63;margin-top:32px;margin-bottom:10px;padding-top:20px;border-top:1px solid #2A2A26;}
.llm h3:first-of-type{border-top:none;padding-top:0;margin-top:0;}
.llm pre{font-family:var(--font-mono);font-size:11px;color:#444440;margin-bottom:20px;line-height:1.8;white-space:pre-wrap;}
.llm ul{list-style:none;padding:0;margin:0 0 16px;}
.llm li{font-family:var(--font-mono);font-size:11px;color:#9A9990;line-height:1.8;padding-left:14px;position:relative;}
.llm li::before{content:"-";position:absolute;left:0;color:#5A5A55;}
.llm code{font-family:var(--font-mono);font-size:11px;color:#E8E8E2;background:#2A2A26;padding:1px 5px;border-radius:3px;}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<!--
spec:agent
id: J5-shopping-list
type: journey-spec
version: 1.0
-->
<div class="doc">
<!-- ── Header ── -->
<div class="doc-header">
<div>
<h1>J5 — Shopping list</h1>
<p>Journey spec — Generate shopping list, real-time shared checklist</p>
</div>
<div class="doc-meta">
v1.0<br/>
<span class="pill">Journey</span>
</div>
</div>
<!-- ═══ J5 SHOPPING ═══ -->
<div class="jh jh-b">
<div class="jn">J5</div>
<div><h2>Generate shopping list</h2><p>Merge ingredients, filter staples. Always live and shared with household.</p><div class="fl">C1 → D1 (always live) · Planner generates · All members add/remove/check off</div></div>
</div>
<!-- ═══ D1 SHOPPING LIST ═══ -->
<div class="scr" id="d1">
<div class="scr-head"><h3>Shopping list (live)</h3><span class="scr-id">D1</span></div>
<div class="scr-desc">V1 Checklist with sources. Desktop: sidebar + topbar + two-column content — checklist on the left, a "This week's recipes" reference panel on the right that shows which recipes contributed which items. The panel is a page section (surface bg), not a card.</div>
<div class="scr-var"><strong>V1 · Checklist with sources</strong> — desktop: list left, recipe reference right</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>10:24</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div class="mtb"><div class="mtb-t">Shopping list</div><div class="mi">⚙️</div></div>
<div style="padding:8px 12px;">
<div style="background:var(--blue-tint);border:1px solid var(--blue-light);border-radius:var(--radius-lg);padding:8px 10px;display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<div style="width:8px;height:8px;border-radius:50%;background:var(--blue);"></div>
<div style="font-size:11px;color:var(--blue-dark);">Shared with household · 2 members online</div>
</div>
<div class="eye" style="margin-bottom:4px;">5 items remaining</div>
<div style="padding:0 4px;">
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Cherry tomatoes</div><div class="ck-s">For: Tomato pasta</div></div><div class="ck-q">500g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Penne pasta</div><div class="ck-s">For: Tomato pasta</div></div><div class="ck-q">300g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Salmon fillet</div><div class="ck-s">For: Salmon teriyaki</div></div><div class="ck-q">400g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Jasmine rice</div><div class="ck-s">For: Teriyaki, Curry</div></div><div class="ck-q">500g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Soy sauce</div><div class="ck-s">For: Teriyaki, Stir-fry</div></div><div class="ck-q">4 tbsp</div></div>
<div class="ck d" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Coconut milk</div><div class="ck-s">For: Thai curry</div></div><div class="ck-q">400ml</div></div>
<div class="ck d" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Curry paste</div><div class="ck-s">For: Thai curry</div></div><div class="ck-q">2 tbsp</div></div>
</div>
<div style="margin-top:12px;text-align:center;font-size:11px;color:var(--blue-dark);font-weight:500;">+ Add custom item</div>
</div>
<div class="mbt"><div class="mt-i"><div class="mt-ic">📅</div><div class="mt-l">Planner</div></div><div class="mt-i"><div class="mt-ic">📖</div><div class="mt-l">Recipes</div></div><div class="mt-i a"><div class="mt-ic">🛒</div><div class="mt-l">Shopping</div></div><div class="mt-i"><div class="mt-ic">⚙️</div><div class="mt-l">Settings</div></div></div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk">
<div class="dsb">
<div class="dsb-logo"><div class="dsb-lm"><div class="dsb-ic">🥗</div><div class="dsb-nm">Mealplan</div></div><div class="dsb-sub">Smith household</div></div>
<div class="dsb-nav"><div><div class="dsb-nl">Plan</div><div class="dsb-ni"><span class="dsb-nc">📅</span>Planner</div><div class="dsb-ni"><span class="dsb-nc">📖</span>Recipes</div><div class="dsb-ni a"><span class="dsb-nc">🛒</span>Shopping</div></div></div>
</div>
<div class="dm">
<div class="dtb"><div class="dtb-t">Shopping list</div><div class="dtb-r"><div style="background:var(--blue-tint);border:1px solid var(--blue-light);border-radius:var(--radius-md);padding:5px 12px;font-size:11px;color:var(--blue-dark);display:flex;align-items:center;gap:6px;"><div style="width:6px;height:6px;border-radius:50%;background:var(--blue);"></div>2 members online</div></div></div>
<div style="flex:1;display:flex;overflow:hidden;">
<!-- Left: checklist -->
<div style="flex:1;padding:20px 24px;overflow-y:auto;">
<div class="eye" style="margin-bottom:10px;">5 items remaining · 2 checked off</div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Cherry tomatoes</div><div class="ck-s">For: Tomato pasta</div></div><div class="ck-q">500g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Penne pasta</div><div class="ck-s">For: Tomato pasta</div></div><div class="ck-q">300g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Salmon fillet</div><div class="ck-s">For: Salmon teriyaki</div></div><div class="ck-q">400g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Jasmine rice</div><div class="ck-s">For: Salmon teriyaki, Thai curry</div></div><div class="ck-q">500g</div></div>
<div class="ck" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Soy sauce</div><div class="ck-s">For: Salmon teriyaki, Stir-fry</div></div><div class="ck-q">4 tbsp</div></div>
<div style="border-top:1px solid var(--color-border);margin-top:12px;padding-top:10px;">
<div class="eye" style="margin-bottom:6px;opacity:.6;">Checked off</div>
<div class="ck d" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Coconut milk</div><div class="ck-s">For: Thai curry</div></div><div class="ck-q">400ml</div></div>
<div class="ck d" onclick="this.classList.toggle('d')"><div class="ck-b"></div><div class="ck-c"><div class="ck-n">Curry paste</div><div class="ck-s">For: Thai curry</div></div><div class="ck-q">2 tbsp</div></div>
</div>
<div style="margin-top:16px;"><span style="font-size:12px;color:var(--blue-dark);font-weight:500;cursor:pointer;">+ Add custom item</span></div>
</div>
<!-- Right: recipe reference -->
<div style="width:280px;flex-shrink:0;border-left:1px solid var(--color-border);background:var(--color-surface);padding:20px;overflow-y:auto;">
<div class="eye" style="margin-bottom:12px;">This week's recipes</div>
<div style="margin-bottom:10px;padding:10px;background:var(--color-page);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-size:13px;font-weight:500;margin-bottom:2px;">Tomato pasta</div>
<div style="font-size:10px;color:var(--color-text-muted);">Tue · 2 ingredients on list</div>
</div>
<div style="margin-bottom:10px;padding:10px;background:var(--color-page);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-size:13px;font-weight:500;margin-bottom:2px;">Salmon teriyaki</div>
<div style="font-size:10px;color:var(--color-text-muted);">Wed · 3 ingredients on list</div>
</div>
<div style="margin-bottom:10px;padding:10px;background:var(--color-page);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-size:13px;font-weight:500;margin-bottom:2px;">Thai green curry</div>
<div style="font-size:10px;color:var(--color-text-muted);">Thu · 2 ingredients on list</div>
</div>
<div style="margin-bottom:10px;padding:10px;background:var(--color-page);border:1px solid var(--color-border);border-radius:var(--radius-md);">
<div style="font-size:13px;font-weight:500;margin-bottom:2px;">Chicken stir-fry</div>
<div style="font-size:10px;color:var(--color-text-muted);">Mon · shared soy sauce</div>
</div>
<div style="border-top:1px solid var(--color-border);margin-top:12px;padding-top:12px;">
<div class="eye" style="margin-bottom:6px;">Filtered staples</div>
<div style="font-size:11px;color:var(--color-text-muted);line-height:1.5;">Olive oil · Garlic · Salt · Pepper · Rice · Pasta</div>
<div style="margin-top:8px;font-size:11px;color:var(--blue);font-weight:500;cursor:pointer;">Edit staples →</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>D1 · Shopping list</h4>
<pre>/* Desktop: 224px sidebar + topbar (title + shared status badge) + 2-col content:
* Left (flex:1, page bg): remaining count + checklist rows + "checked off" section + add custom
* Right (280px, surface bg): recipe reference cards + filtered staples list + edit staples link
* Recipe reference panel: page-section with surface bg, not a floating card.
* Mobile: full-width checklist + shared banner + bottom tabs.
* Real-time sync: is_checked updates broadcast to all connected clients.
* Both roles: planner + member can view and check off. Only planner can regenerate. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop</td></tr>
<tr><td>Checklist area</td><td>flex:1, page bg, 20px 24px padding</td><td>Remaining items + divider + checked items</td></tr>
<tr><td>Recipe reference</td><td>280px, surface bg, border-left</td><td>Recipe name + day + ingredient count. Filtered staples below.</td></tr>
<tr class="grp"><td colspan="3">Shared state</td></tr>
<tr><td>Banner (mobile)</td><td>blue-tint, blue dot, radius-lg</td><td>"Shared · N online"</td></tr>
<tr><td>Badge (desktop)</td><td>blue-tint pill in topbar</td><td>Compact: dot + "N members online"</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════
LLM INSTRUCTION SECTION
════════════════════════════════════════════════════════ -->
<!--
spec:agent:start
document: J5 Shopping List Journey
version: 1.0
-->
<div class="llm">
<h2>LLM instructions — J5 Shopping list journey</h2>
<p>Authoritative implementation reference for the shopping list journey. Covers screen D1 and references A3/D3 (staples). Use this when building or modifying the shopping list feature.</p>
<h3>1. Journey flow</h3>
<pre>/* J5 flow
* C1 (week confirmed) → D1 (shopping list, always live).
* Actor: Planner generates the list. All household members shop (view, check off, add items).
* Preconditions: J1 (recipes exist) + J2 (week is planned) for generating a list.
* J6 (household setup) for shared access.
* There is NO draft/publish workflow — the list is always live. */</pre>
<h3>2. Screen D1 — Shopping list (live shared)</h3>
<pre>/* Mobile layout:
* topbar (title + settings icon)
* + blue-tint shared banner ("Shared with household · N members online", blue dot)
* + checklist (unchecked items, then checked items below divider)
* + "+ Add custom item" link (blue-dark, centred)
* + bottom tabs (Planner | Recipes | Shopping [active] | Settings)
*
* Desktop layout:
* sidebar (224px, dsb) + topbar (dtb: title + blue-tint "N members online" badge)
* + split content area:
* Left: checklist (flex:1, page bg, 20px 24px padding)
* - eyebrow "N items remaining · N checked off"
* - unchecked rows
* - border-top divider → "Checked off" eyebrow (opacity .6) → checked rows
* - "+ Add custom item" link (12px, blue-dark, font-weight 500)
* Right: recipe reference panel (280px, surface bg, border-left, 20px padding)
* - eyebrow "This week's recipes"
* - recipe cards: page bg, border, radius-md, 10px padding
* - recipe name (13px/500) + day + ingredient count (10px muted)
* - border-top divider → "Filtered staples" eyebrow
* - staple names inline (11px muted, dot-separated)
* - "Edit staples →" link (11px, blue, font-weight 500) — links to D3
*
* Checklist row (.ck):
* checkbox (.ck-b, 22px, radius 4px, 2px border)
* + content (.ck-c): name (.ck-n, 14px) + source (.ck-s, 10px muted, "For: [recipe names]")
* + quantity (.ck-q, mono 12px muted, flex-shrink 0)
*
* Checked state (.ck.d):
* checkbox fills green with white checkmark
* name gets line-through + muted colour
* row moves below divider into "Checked off" section */</pre>
<h3>3. Shopping list generation</h3>
<ul>
<li>Only the planner role can generate or regenerate the shopping list</li>
<li>Ingredients from ALL planned meals for the week are collected</li>
<li>Shared ingredients are merged and quantities summed (e.g. "Jasmine rice: 500g" from "Salmon teriyaki" + "Thai curry")</li>
<li>Pantry staples (defined in A3/D3) are automatically filtered out</li>
<li>The "For:" source line shows ALL recipes that use that ingredient</li>
<li>Filtered staples are listed in the recipe reference panel (desktop) for transparency</li>
</ul>
<h3>4. Real-time sync</h3>
<pre>/* Real-time rules:
* - is_checked updates broadcast to ALL connected clients instantly
* - "N members online" indicator shows who is currently viewing the shopping list
* - Prevents double-buying when multiple family members shop simultaneously or at different times
* - Blue accent colour for all shared-state UI:
* Mobile: blue-tint banner with blue dot
* Desktop: blue-tint badge in topbar with blue dot
* - WebSocket or SSE for real-time — implementation choice, but must be instant */</pre>
<h3>5. Custom items</h3>
<ul>
<li>Any household member (planner or member) can add items not on the generated list</li>
<li>Use case: household supplies, snacks, items forgotten by the planner</li>
<li>Custom items appear at the bottom of the unchecked list</li>
<li>"+ Add custom item" link — blue-dark colour, centred (mobile) or left-aligned (desktop)</li>
<li>Custom items can be checked off and removed just like generated items</li>
</ul>
<h3>6. Data operations</h3>
<pre>/* Generate list:
* SELECT ri.ingredient_id, i.name, SUM(ri.quantity), ri.unit
* FROM recipe_ingredient ri
* JOIN ingredient i ON ri.ingredient_id = i.id
* JOIN week_plan_slot wps ON wps.recipe_id = ri.recipe_id
* WHERE wps.week = :current_week
* AND i.is_staple = false
* GROUP BY ri.ingredient_id, i.name, ri.unit
*
* Check off:
* UPDATE shopping_list_item
* SET is_checked = true/false
* WHERE id = :item_id
* → broadcast change to all connected clients via real-time channel
*
* Add custom:
* INSERT INTO shopping_list_item (name, quantity, is_custom, is_checked, shopping_list_id)
* VALUES (:name, :quantity, true, false, :list_id)
* → broadcast new item to all connected clients */</pre>
<h3>7. Design constraints</h3>
<ul>
<li>List is ALWAYS live — no draft/publish workflow, no approval step</li>
<li>Both planner and member can: view, check off, add custom items, remove items</li>
<li>Only planner can: generate list, regenerate list</li>
<li>"Edit staples" link navigates to D3 (same component as A3 — build once, reference from two entry points)</li>
<li>CalDAV export is future scope (E3) — do not build in v1</li>
<li>Recipe reference panel is a page section with surface bg — NOT a floating card</li>
<li>Blue accent colour is reserved for shared/collaborative state indicators</li>
<li>Checked items must visually separate from unchecked via a divider and "Checked off" label</li>
</ul>
<h3>8. Preconditions</h3>
<pre>/* Precondition chain:
* J1 (recipes exist) — cannot generate a shopping list without recipes
* J2 (week is planned) — cannot generate a shopping list without planned meals
* J6 (household setup) — required for shared access (multiple members online)
*
* If no meals are planned: show empty state on D1 with prompt to plan the week first
* If no household members: list works for solo planner, shared banner is hidden */</pre>
</div>
<!--
spec:agent:end
-->
</div>
</body>
</html>

View File

@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>J6 — Household Setup · Screen Specs</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<style>
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--green-tint:#E8F5EA;--green-light:#AEDCB0;--green:#3D8C4A;--green-dark:#2E6E39;--green-deeper:#1E4A26;--yellow-tint:#FDF6D8;--yellow-light:#F9E08A;--yellow:#F2C12E;--yellow-dark:#C49610;--yellow-text:#8A6800;--blue-tint:#E6F1FB;--blue-light:#A4CFF4;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.doc-header p{font-size:13px;color:var(--color-text-muted);}
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;background:var(--green-tint);color:var(--green-dark);}
.section{margin-bottom:64px;}
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
.jh-p{background:var(--purple-tint);border:1px solid #CECBF6;}.jh-p .jn{color:var(--purple);}.jh-p p,.jh-p .fl{color:var(--purple-dark);}
.scr{margin-bottom:56px;}
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;min-height:520px;}
.fi{width:100%;font-family:var(--font-sans);font-size:14px;padding:10px 12px;border-radius:var(--radius-md);border:1px solid var(--color-border);background:var(--color-page);color:var(--color-text);outline:none;}
.fl{font-size:12px;font-weight:500;color:var(--color-text);margin-bottom:6px;display:block;}
.fg{margin-bottom:16px;}
.bp{font-family:var(--font-sans);font-size:14px;font-weight:500;padding:12px 24px;border-radius:var(--radius-md);background:var(--green);color:#fff;border:none;cursor:pointer;width:100%;}
.bg{font-family:var(--font-sans);font-size:13px;font-weight:500;padding:10px 20px;border-radius:var(--radius-md);background:var(--color-subtle);color:var(--color-text-muted);border:1px solid var(--color-border);cursor:pointer;}
.tc{display:inline-flex;font-size:12px;font-weight:500;padding:6px 12px;border-radius:20px;border:1px solid var(--color-border);background:var(--color-surface);color:var(--color-text-muted);cursor:pointer;margin:0 4px 4px 0;user-select:none;}.tc.s{background:var(--green-tint);color:var(--green-dark);border-color:var(--green-light);}
.eye{font-size:10px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:var(--color-text-muted);}
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
/* LLM section */
.llm{background:var(--color-page);border:2px solid var(--green);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--green-dark);}
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
.llm li{margin-bottom:4px;}
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
.llm td{color:var(--color-text-muted);}
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
</style>
</head>
<body>
<div class="doc">
<div class="doc-header">
<div>
<h1>J6 — Household setup</h1>
<p>One-time onboarding journey · Screens A1, A2, A3/D3, A4</p>
</div>
<div class="doc-meta">
<span class="pill">v1.0</span><br>
Screens: 4<br>
Flow: A1 → A2 → A3 → A2 → [invite] → A4
</div>
</div>
<div class="jh jh-p">
<div class="jn">J6</div>
<div><h2>Household setup</h2><p>One-time onboarding. Account creation → household naming → staples → invite.</p><div class="fl">A1 → A2 → A3 → A2 → [invite] → A4</div></div>
</div>
<!-- ═══ A1 SIGN UP ═══ -->
<div class="scr" id="a1">
<div class="scr-head"><h3>Sign up</h3><span class="scr-id">A1</span></div>
<div class="scr-desc">First screen. Creates user_account. No navigation chrome (pre-auth). Desktop: full-viewport split — brand identity left, signup form right. Not a centered card — the brand section fills the entire left half.</div>
<div class="scr-var"><strong>V2 · Split layout</strong> — brand left fills viewport height, form right</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:41</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="background:var(--green-dark);padding:28px 24px;text-align:center;">
<div style="font-size:28px;margin-bottom:8px;">🥗</div>
<div style="font-family:var(--font-display);font-size:22px;font-weight:500;color:#fff;">Mealplan</div>
<div style="font-size:12px;color:var(--green-light);margin-top:4px;">Plan meals, eat well, waste less</div>
</div>
<div style="padding:24px 20px;">
<div style="font-family:var(--font-display);font-size:18px;font-weight:500;margin-bottom:4px;">Create your account</div>
<div style="font-size:12px;color:var(--color-text-muted);margin-bottom:20px;">You'll set up your household next.</div>
<div class="fg"><label class="fl">Your name</label><input class="fi" placeholder="e.g. Sarah"/></div>
<div class="fg"><label class="fl">Email</label><input class="fi" placeholder="you@example.com"/></div>
<div class="fg"><label class="fl">Password</label><input class="fi" type="password" placeholder="At least 8 characters"/></div>
<button class="bp">Create account →</button>
<div style="text-align:center;margin-top:16px;font-size:12px;color:var(--color-text-muted);">Already have an account? <span style="color:var(--green);font-weight:500;">Log in</span></div>
</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk" style="min-height:500px;">
<div style="width:440px;flex-shrink:0;background:var(--green-dark);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px 40px;">
<div style="font-size:64px;margin-bottom:20px;">🥗</div>
<div style="font-family:var(--font-display);font-size:36px;font-weight:500;color:#fff;letter-spacing:-.02em;margin-bottom:10px;">Mealplan</div>
<div style="font-size:15px;color:var(--green-light);text-align:center;line-height:1.6;max-width:280px;">Plan your week's dinners. Get variety-aware suggestions. Shop together as a household.</div>
<div style="margin-top:32px;display:flex;gap:12px;">
<div style="background:rgba(255,255,255,.1);border-radius:8px;padding:12px 16px;text-align:center;"><div style="font-size:18px;margin-bottom:4px;">📅</div><div style="font-size:10px;color:var(--green-light);">Plan</div></div>
<div style="background:rgba(255,255,255,.1);border-radius:8px;padding:12px 16px;text-align:center;"><div style="font-size:18px;margin-bottom:4px;">🍳</div><div style="font-size:10px;color:var(--green-light);">Cook</div></div>
<div style="background:rgba(255,255,255,.1);border-radius:8px;padding:12px 16px;text-align:center;"><div style="font-size:18px;margin-bottom:4px;">🛒</div><div style="font-size:10px;color:var(--green-light);">Shop</div></div>
</div>
</div>
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:48px 56px;">
<div style="max-width:380px;">
<div style="font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px;">Create your account</div>
<div style="font-size:14px;color:var(--color-text-muted);margin-bottom:32px;">You'll set up your household next.</div>
<div class="fg"><label class="fl">Your name</label><input class="fi" placeholder="e.g. Sarah"/></div>
<div class="fg"><label class="fl">Email</label><input class="fi" placeholder="you@example.com"/></div>
<div class="fg"><label class="fl">Password</label><input class="fi" type="password" placeholder="At least 8 characters"/></div>
<button class="bp" style="margin-top:8px;">Create account →</button>
<div style="text-align:center;margin-top:20px;font-size:13px;color:var(--color-text-muted);">Already have an account? <span style="color:var(--green);font-weight:500;cursor:pointer;">Log in</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>A1 · Sign up</h4>
<pre>/* Pre-auth. No nav chrome. Desktop: full-viewport 2-col split. Brand ~42% / Form ~58%.
* Mobile: brand as ~120px banner, form below.
* Brand section: green-dark bg. Contains logo, tagline, 3 feature icons.
* Form section: page bg. Form max-width 380px. Not a card — just content on the page.
* Writes: user_account INSERT → redirect A2 */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop layout</td></tr>
<tr><td>Brand panel</td><td>440px fixed, green-dark bg, content centered vertically</td><td>Logo 64px + name Fraunces 36px + tagline 15px + 3 feature icons</td></tr>
<tr><td>Form panel</td><td>flex:1, page bg, content centered vertically, 48px 56px padding</td><td>Form max-width 380px. Not wrapped in a card.</td></tr>
<tr class="grp"><td colspan="3">Mobile layout</td></tr>
<tr><td>Brand banner</td><td>~120px, green-dark bg, centered</td><td>Logo 28px + name 22px + tagline 12px</td></tr>
<tr><td>Form</td><td>Full width, 24px 20px padding</td><td>Same fields, smaller type sizes</td></tr>
<tr class="grp"><td colspan="3">Data</td></tr>
<tr><td>Writes</td><td>user_account INSERT</td><td>system_role='user', is_active=true → A2</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ A2 HOUSEHOLD SETUP ═══ -->
<div class="scr" id="a2">
<div class="scr-head"><h3>Household setup</h3><span class="scr-id">A2</span></div>
<div class="scr-desc">Name household + optional member invite. Desktop: full-page layout with a left illustration/progress column and the form on the right. Not a floating card — a structured page with clear sections.</div>
<div class="scr-var"><strong>V1 · Simple form</strong> — desktop: progress sidebar left, form right</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:42</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="padding:24px 20px;">
<div class="eye" style="margin-bottom:4px;">Step 1 of 3</div>
<div style="font-family:var(--font-display);font-size:20px;font-weight:500;margin-bottom:6px;">Name your household</div>
<div style="font-size:12px;color:var(--color-text-muted);margin-bottom:20px;">This appears in the sidebar and is shared with all members.</div>
<div class="fg"><label class="fl">Household name</label><input class="fi" value="Smith family"/></div>
<div style="border-top:1px solid var(--color-border);padding-top:20px;margin-top:8px;">
<div class="eye" style="margin-bottom:8px;">Invite members (optional)</div>
<div style="font-size:12px;color:var(--color-text-muted);margin-bottom:12px;">You can skip this and invite people later from settings.</div>
<button class="bg" style="width:100%;font-size:12px;">+ Generate invite link</button>
</div>
<div style="margin-top:24px;"><button class="bp">Continue → Set up staples</button></div>
</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk" style="min-height:480px;">
<!-- Left: progress / branding strip -->
<div style="width:300px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);padding:40px 28px;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:40px;">
<div style="width:28px;height:28px;border-radius:6px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:14px;">🥗</div>
<div style="font-family:var(--font-display);font-size:16px;font-weight:500;">Mealplan</div>
</div>
<!-- Progress steps -->
<div style="display:flex;flex-direction:column;gap:24px;flex:1;">
<div style="display:flex;gap:12px;align-items:flex-start;">
<div style="width:28px;height:28px;border-radius:50%;background:var(--green);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">1</div>
<div><div style="font-size:13px;font-weight:500;color:var(--color-text);">Name your household</div><div style="font-size:11px;color:var(--color-text-muted);margin-top:2px;">Give your family a name</div></div>
</div>
<div style="display:flex;gap:12px;align-items:flex-start;">
<div style="width:28px;height:28px;border-radius:50%;background:var(--color-subtle);color:var(--color-text-muted);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">2</div>
<div><div style="font-size:13px;color:var(--color-text-muted);">Set up pantry staples</div><div style="font-size:11px;color:var(--color-text-muted);margin-top:2px;">What you always have at home</div></div>
</div>
<div style="display:flex;gap:12px;align-items:flex-start;">
<div style="width:28px;height:28px;border-radius:50%;background:var(--color-subtle);color:var(--color-text-muted);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">3</div>
<div><div style="font-size:13px;color:var(--color-text-muted);">Invite members</div><div style="font-size:11px;color:var(--color-text-muted);margin-top:2px;">Share with your household</div></div>
</div>
</div>
</div>
<!-- Right: form content -->
<div style="flex:1;padding:48px 56px;display:flex;flex-direction:column;justify-content:center;">
<div style="max-width:420px;">
<div style="font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px;">Name your household</div>
<div style="font-size:14px;color:var(--color-text-muted);margin-bottom:28px;">This name appears in the sidebar and is shared with all members.</div>
<div class="fg"><label class="fl">Household name</label><input class="fi" value="Smith family" style="font-size:16px;padding:12px 14px;"/></div>
<div style="border-top:1px solid var(--color-border);padding-top:24px;margin-top:8px;">
<div class="eye" style="margin-bottom:10px;">Invite members (optional)</div>
<div style="font-size:13px;color:var(--color-text-muted);margin-bottom:14px;">You can skip this and invite people later from settings.</div>
<button class="bg" style="width:100%;font-size:13px;padding:12px;">+ Generate invite link</button>
</div>
<div style="margin-top:32px;"><button class="bp" style="font-size:15px;padding:14px;">Continue → Set up staples</button></div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>A2 · Household setup</h4>
<pre>/* Desktop: progress sidebar (300px, surface bg) + form area (flex:1, page bg).
* Progress sidebar shows: app logo + 3 numbered steps (current = green circle, future = subtle).
* Form area: content centered vertically, max-width 420px. Not a card — content on page bg.
* Mobile: full-width form with step indicator text at top. No progress sidebar.
* Writes: household INSERT + household_member INSERT (role=planner) → A3 */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop layout</td></tr>
<tr><td>Progress sidebar</td><td>300px, surface bg, border-right</td><td>App logo top + numbered steps. Current step: green circle.</td></tr>
<tr><td>Form area</td><td>flex:1, page bg, centered content</td><td>max-width 420px. Larger input (16px, 12px padding).</td></tr>
<tr><td>Step circles</td><td>28px diameter. Active: green bg #fff text. Future: subtle bg muted text.</td><td>Labels: 13px name + 11px description below</td></tr>
<tr class="grp"><td colspan="3">Mobile</td></tr>
<tr><td>Step indicator</td><td>"Step 1 of 3" eyebrow text</td><td>No sidebar, text-only progress</td></tr>
<tr><td>Form</td><td>Full width, 24px 20px padding</td><td>Same fields</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ A3 PANTRY STAPLES ═══ -->
<div class="scr" id="a3">
<div class="scr-head"><h3>Pantry staples</h3><span class="scr-id">A3 / D3</span></div>
<div class="scr-desc">Toggle staple ingredients by category. Desktop: same progress sidebar as A2 (step 2 active) + content area with a multi-column chip grid. The chips flow naturally across the full content width — no card wrapper needed.</div>
<div class="scr-var"><strong>V2 · Categorized list</strong> — desktop: progress sidebar + 2-col category grid</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>9:43</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="padding:20px;">
<div class="eye" style="margin-bottom:4px;">Step 2 of 3</div>
<div style="font-family:var(--font-display);font-size:20px;font-weight:500;margin-bottom:6px;">Your pantry staples</div>
<div style="font-size:12px;color:var(--color-text-muted);margin-bottom:16px;">These won't appear on your shopping list. Tap to toggle.</div>
<div class="eye" style="margin:12px 0 6px;">Oils &amp; fats</div>
<div><span class="tc s" onclick="this.classList.toggle('s')">Olive oil</span><span class="tc s" onclick="this.classList.toggle('s')">Butter</span><span class="tc" onclick="this.classList.toggle('s')">Coconut oil</span></div>
<div class="eye" style="margin:16px 0 6px;">Spices</div>
<div><span class="tc s" onclick="this.classList.toggle('s')">Salt</span><span class="tc s" onclick="this.classList.toggle('s')">Pepper</span><span class="tc s" onclick="this.classList.toggle('s')">Garlic</span><span class="tc" onclick="this.classList.toggle('s')">Cumin</span><span class="tc" onclick="this.classList.toggle('s')">Paprika</span></div>
<div class="eye" style="margin:16px 0 6px;">Grains &amp; pasta</div>
<div><span class="tc s" onclick="this.classList.toggle('s')">Rice</span><span class="tc s" onclick="this.classList.toggle('s')">Pasta</span><span class="tc" onclick="this.classList.toggle('s')">Flour</span></div>
<div class="eye" style="margin:16px 0 6px;">Sauces</div>
<div><span class="tc s" onclick="this.classList.toggle('s')">Soy sauce</span><span class="tc" onclick="this.classList.toggle('s')">Fish sauce</span><span class="tc s" onclick="this.classList.toggle('s')">Vinegar</span></div>
<div style="margin-top:20px;"><button class="bp">Continue → Invite members</button></div>
</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk" style="min-height:500px;">
<!-- Progress sidebar (same as A2 but step 2 active) -->
<div style="width:300px;flex-shrink:0;background:var(--color-surface);border-right:1px solid var(--color-border);padding:40px 28px;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:40px;"><div style="width:28px;height:28px;border-radius:6px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:14px;">🥗</div><div style="font-family:var(--font-display);font-size:16px;font-weight:500;">Mealplan</div></div>
<div style="display:flex;flex-direction:column;gap:24px;">
<div style="display:flex;gap:12px;align-items:flex-start;"><div style="width:28px;height:28px;border-radius:50%;background:var(--green-tint);color:var(--green-dark);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;"></div><div><div style="font-size:13px;color:var(--green-dark);">Name your household</div><div style="font-size:11px;color:var(--green-dark);margin-top:2px;">Smith family</div></div></div>
<div style="display:flex;gap:12px;align-items:flex-start;"><div style="width:28px;height:28px;border-radius:50%;background:var(--green);color:#fff;display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">2</div><div><div style="font-size:13px;font-weight:500;color:var(--color-text);">Set up pantry staples</div><div style="font-size:11px;color:var(--color-text-muted);margin-top:2px;">What you always have at home</div></div></div>
<div style="display:flex;gap:12px;align-items:flex-start;"><div style="width:28px;height:28px;border-radius:50%;background:var(--color-subtle);color:var(--color-text-muted);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:500;flex-shrink:0;">3</div><div><div style="font-size:13px;color:var(--color-text-muted);">Invite members</div></div></div>
</div>
</div>
<!-- Content: chips in natural 2-column flow -->
<div style="flex:1;padding:48px 56px;overflow-y:auto;">
<div style="font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px;">Your pantry staples</div>
<div style="font-size:14px;color:var(--color-text-muted);margin-bottom:28px;max-width:500px;">These items won't appear on your shopping list. Click to toggle. You can always change these later in settings.</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:24px 32px;margin-bottom:32px;">
<div><div class="eye" style="margin-bottom:8px;">Oils &amp; fats</div><div><span class="tc s">Olive oil</span><span class="tc s">Butter</span><span class="tc">Coconut oil</span><span class="tc s">Vegetable oil</span></div></div>
<div><div class="eye" style="margin-bottom:8px;">Spices &amp; seasonings</div><div><span class="tc s">Salt</span><span class="tc s">Pepper</span><span class="tc s">Garlic</span><span class="tc">Cumin</span><span class="tc">Paprika</span><span class="tc s">Chili flakes</span><span class="tc">Oregano</span><span class="tc">Cinnamon</span></div></div>
<div><div class="eye" style="margin-bottom:8px;">Grains &amp; pasta</div><div><span class="tc s">Rice</span><span class="tc s">Pasta</span><span class="tc">Flour</span><span class="tc">Breadcrumbs</span><span class="tc">Couscous</span></div></div>
<div><div class="eye" style="margin-bottom:8px;">Sauces &amp; condiments</div><div><span class="tc s">Soy sauce</span><span class="tc">Fish sauce</span><span class="tc s">Vinegar</span><span class="tc">Mustard</span><span class="tc">Ketchup</span></div></div>
<div><div class="eye" style="margin-bottom:8px;">Baking</div><div><span class="tc s">Sugar</span><span class="tc">Baking powder</span><span class="tc">Vanilla extract</span></div></div>
<div><div class="eye" style="margin-bottom:8px;">Dairy &amp; basics</div><div><span class="tc s">Eggs</span><span class="tc s">Milk</span><span class="tc">Cream</span><span class="tc">Cheese</span></div></div>
</div>
<button class="bp" style="max-width:320px;font-size:15px;padding:14px;">Continue → Invite members</button>
<div style="margin-top:10px;font-size:12px;color:var(--color-text-muted);cursor:pointer;">Skip for now</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>A3/D3 · Pantry staples</h4>
<pre>/* Desktop onboarding: progress sidebar (300px, step 2 active) + content area with 2-col category grid.
* Categories flow in a CSS grid (2 columns, 24px row gap, 32px col gap).
* Chips sit directly on the page bg — no card wrappers.
* Desktop settings (D3): same chip grid but inside sidebar+topbar layout (no progress sidebar).
* Mobile: single-column chip list, full width.
* Chip toggle: UPDATE ingredient SET is_staple. Debounced 300ms. */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop onboarding</td></tr>
<tr><td>Progress sidebar</td><td>300px, surface bg. Step 1 = ✓ completed. Step 2 = green active.</td><td>Same sidebar as A2, reused component.</td></tr>
<tr><td>Category grid</td><td>grid-template-columns: 1fr 1fr. gap: 24px 32px.</td><td>Categories from ingredient_category, sorted by sort_order.</td></tr>
<tr><td>Chips</td><td>12px/500, 6px 12px pad, 20px radius. Selected: green-tint/green-light/green-dark.</td><td>Direct on page bg. No card wrapper.</td></tr>
<tr class="grp"><td colspan="3">Desktop settings (D3)</td></tr>
<tr><td>Layout</td><td>App sidebar (224px) + topbar ("Staples") + content with 3-col grid</td><td>Wider content area → 3 columns for categories.</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══ A4 JOIN HOUSEHOLD ═══ -->
<div class="scr" id="a4">
<div class="scr-head"><h3>Join household</h3><span class="scr-id">A4</span></div>
<div class="scr-desc">Member accepts invite. Desktop: split layout like A1 — left side shows the household identity (name, who invited), right side has the signup form. The left panel uses green-tint (not green-dark) since this is a welcoming join screen, not a brand-first screen.</div>
<div class="scr-var"><strong>V2 · Welcome card</strong> — desktop: household identity left, join form right</div>
<div class="previews">
<div class="prev-col">
<div class="bp-lbl">Mobile · 320px</div>
<div class="phone">
<div class="pst"><b>10:15</b><span>●●● WiFi 🔋</span></div>
<div class="pb">
<div style="background:var(--green-tint);padding:28px 24px;text-align:center;border-bottom:1px solid var(--green-light);">
<div style="width:48px;height:48px;border-radius:12px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:22px;margin:0 auto 12px;">🥗</div>
<div style="font-family:var(--font-display);font-size:22px;font-weight:500;color:var(--green-deeper);margin-bottom:4px;">Smith family</div>
<div style="font-size:12px;color:var(--green-dark);">Sarah invited you to join their meal planner</div>
</div>
<div style="padding:24px 20px;">
<div style="background:var(--green-tint);border:1px solid var(--green-light);border-radius:var(--radius-lg);padding:10px 12px;margin-bottom:20px;font-size:11px;color:var(--green-dark);line-height:1.5;">You'll be able to view the weekly meal plan and check off items on the shared shopping list.</div>
<div class="fg"><label class="fl">Your name</label><input class="fi" placeholder="e.g. Tom"/></div>
<div class="fg"><label class="fl">Email</label><input class="fi" placeholder="you@example.com"/></div>
<div class="fg"><label class="fl">Password</label><input class="fi" type="password" placeholder="At least 8 characters"/></div>
<button class="bp">Join household</button>
</div>
</div>
</div>
</div>
<div class="prev-col">
<div class="bp-lbl">Desktop · 1040px</div>
<div class="desk" style="min-height:480px;">
<!-- Left: household identity -->
<div style="width:400px;flex-shrink:0;background:var(--green-tint);border-right:1px solid var(--green-light);display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px 40px;text-align:center;">
<div style="width:64px;height:64px;border-radius:16px;background:var(--green);display:flex;align-items:center;justify-content:center;font-size:32px;margin-bottom:20px;">🥗</div>
<div style="font-family:var(--font-display);font-size:32px;font-weight:500;color:var(--green-deeper);letter-spacing:-.02em;margin-bottom:8px;">Smith family</div>
<div style="font-size:14px;color:var(--green-dark);margin-bottom:24px;">Sarah invited you to join</div>
<div style="background:rgba(255,255,255,.5);border-radius:var(--radius-lg);padding:16px 20px;max-width:280px;">
<div style="font-size:12px;font-weight:500;color:var(--green-dark);margin-bottom:8px;">What you'll be able to do:</div>
<div style="font-size:12px;color:var(--green-dark);line-height:1.6;text-align:left;">
<div style="padding:3px 0;">📅 View the weekly meal plan</div>
<div style="padding:3px 0;">🛒 Check off shopping list items</div>
<div style="padding:3px 0;"> Add items to the list</div>
</div>
</div>
</div>
<!-- Right: form -->
<div style="flex:1;display:flex;flex-direction:column;justify-content:center;padding:48px 56px;">
<div style="max-width:380px;">
<div style="font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:6px;">Join the household</div>
<div style="font-size:14px;color:var(--color-text-muted);margin-bottom:28px;">Create your account to get started.</div>
<div class="fg"><label class="fl">Your name</label><input class="fi" placeholder="e.g. Tom"/></div>
<div class="fg"><label class="fl">Email</label><input class="fi" placeholder="you@example.com"/></div>
<div class="fg"><label class="fl">Password</label><input class="fi" type="password" placeholder="At least 8 characters"/></div>
<button class="bp">Join household</button>
<div style="margin-top:16px;font-size:12px;color:var(--color-text-muted);">Already have an account? <span style="color:var(--green);font-weight:500;">Log in instead</span></div>
</div>
</div>
</div>
</div>
</div>
<div class="agent">
<h4>A4 · Join household</h4>
<pre>/* Desktop: split layout. Left (400px, green-tint bg): household identity + permissions list.
* Right (flex:1, page bg): signup form max-width 380px.
* Left panel uses green-tint (welcoming) not green-dark (brand-first).
* Mobile: green-tint banner at top + form below.
* Transaction: user_account INSERT + household_member INSERT (role=member) + household_invite UPDATE → C1 */</pre>
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
<tr class="grp"><td colspan="3">Desktop</td></tr>
<tr><td>Identity panel</td><td>400px, green-tint bg, centered content</td><td>Logo 64px + name Fraunces 32px + inviter + permissions list</td></tr>
<tr><td>Form panel</td><td>flex:1, page bg, max-width 380px</td><td>Same signup fields as A1</td></tr>
</tbody></table>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
LLM IMPLEMENTATION GUIDE
═══════════════════════════════════════════════════════════════ -->
<div class="llm">
<h2>Implementation Guide — J6 Household Setup</h2>
<p>This journey is completed once when the app is first used. The planner creates an account, names the household, defines pantry staples, and invites household members.</p>
<h3>Journey Flow</h3>
<ol>
<li><strong>A1 — Sign up:</strong> New user creates account with name, email, password. Writes <code>user_account</code> → redirects to A2.</li>
<li><strong>A2 — Household setup:</strong> Planner names household (e.g. "Smith family"), optionally generates invite link. Writes <code>household</code> + <code>household_member</code> (role=planner) → redirects to A3.</li>
<li><strong>A3 — Pantry staples:</strong> Toggle staple ingredients by category. Debounced 300ms save. Updates <code>ingredient.is_staple</code>. Same component as D3 in settings.</li>
<li><strong>A4 — Join household:</strong> Invited member opens link, sees household identity, creates account. Writes <code>user_account</code> + <code>household_member</code> (role=member) + updates <code>household_invite</code> → redirects to C1.</li>
</ol>
<h3>Roles</h3>
<table>
<tr><th>Role</th><th>Created at</th><th>Access</th></tr>
<tr><td><code>planner</code></td><td>A2 (automatic)</td><td>Full access to all 15 screens</td></tr>
<tr><td><code>member</code></td><td>A4 (on invite accept)</td><td>C1 read-only + D1 view/check/add</td></tr>
</table>
<h3>Layout Rules</h3>
<ul>
<li><strong>All screens are pre-auth</strong> — no sidebar, no tab bar, no navigation chrome.</li>
<li><strong>A1, A4 desktop:</strong> Full-viewport 2-column split. Brand/identity left (fixed width), form right (flex:1).</li>
<li><strong>A2, A3 desktop:</strong> Progress sidebar (300px) left + form/content right (flex:1).</li>
<li><strong>Mobile:</strong> All screens are stacked single-column. Brand/identity as a banner at top, form below.</li>
</ul>
<h3>Design Constraints</h3>
<ul>
<li>A1 brand panel: <code>--green-dark</code> bg (brand-first). A4 identity panel: <code>--green-tint</code> bg (welcoming).</li>
<li>A3 = D3 — single component, two contexts. Onboarding (progress sidebar + 2-col grid) vs settings (app sidebar + 3-col grid).</li>
<li>Chip toggle: selected = <code>--green-tint</code> bg + <code>--green-light</code> border + <code>--green-dark</code> text.</li>
<li>Progress steps: current = green circle, completed = green-tint + checkmark, future = subtle circle.</li>
<li>Invite mechanism: link or short code. No in-app email system. Planner shares via messaging app.</li>
<li>Form max-width: 380px (A1, A4) or 420px (A2). Never wider.</li>
</ul>
<h3>Data Operations</h3>
<table>
<tr><th>Screen</th><th>Writes</th><th>Reads</th></tr>
<tr><td>A1</td><td><code>user_account INSERT</code></td><td></td></tr>
<tr><td>A2</td><td><code>household INSERT</code>, <code>household_member INSERT (role=planner)</code>, <code>household_invite INSERT</code> (optional)</td><td></td></tr>
<tr><td>A3/D3</td><td><code>ingredient UPDATE (is_staple)</code> — debounced 300ms</td><td><code>ingredient SELECT</code> grouped by <code>ingredient_category</code></td></tr>
<tr><td>A4</td><td><code>user_account INSERT</code>, <code>household_member INSERT (role=member)</code>, <code>household_invite UPDATE</code></td><td><code>household_invite SELECT</code> (validates code), <code>household SELECT</code> (name, inviter)</td></tr>
</table>
<h3>Accessibility</h3>
<ul>
<li>All forms: semantic <code>&lt;form&gt;</code>, <code>&lt;label&gt;</code> with <code>for</code>, visible focus indicators.</li>
<li>Chip toggles (A3): use <code>role="checkbox"</code> or <code>&lt;input type="checkbox"&gt;</code> with visual styling.</li>
<li>Progress steps (A2, A3): use <code>aria-current="step"</code> on the active step.</li>
<li>Contrast: all text meets WCAG 2.2 AA (4.5:1 normal, 3:1 large).</li>
</ul>
<h3>Preconditions &amp; Dependencies</h3>
<ul>
<li>J6 is a <strong>precondition for all other journeys</strong> — user must have an account and household.</li>
<li>J6 is a precondition for J5 shared list — household members must be invited before the list is shared.</li>
<li>A3 configures staples that J5 (shopping list) uses for smart filtering.</li>
<li>No other screens are accessible to household members in v1 besides C1 (read-only) and D1.</li>
</ul>
</div>
</div>
</body>
</html>