diff --git a/docs/superpowers/plans/2026-04-17-kochwas-overview.md b/docs/superpowers/plans/2026-04-17-kochwas-overview.md
new file mode 100644
index 0000000..5ae28ed
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-17-kochwas-overview.md
@@ -0,0 +1,33 @@
+# Kochwas — Implementation Plan Overview
+
+**Goal:** Deliver the Kochwas MVP per the design spec in six phases, each ending in runnable, testable software.
+
+**Reference:** `docs/superpowers/specs/2026-04-17-kochwas-design.md`
+
+---
+
+## Phase Map
+
+| # | Name | Outcome | Plan file |
+|---|------|---------|-----------|
+| 1 | Foundations | Scaffolded SvelteKit app, SQLite schema with FTS5, Docker dev setup, pure-function modules (ingredient-parser, scaler, json-ld-extractor, iso8601) all unit-tested, `/api/health` endpoint | `2026-04-17-kochwas-phase-1-foundations.md` |
+| 2 | Import Pipeline | Working recipe import from URL to DB incl. images, preview endpoint, profile CRUD, integration tests with real HTML fixtures | `2026-04-xx-kochwas-phase-2-import.md` |
+| 3 | Core UI & Local Search | RecipeView component (mobile-first), Search page with FTS5 results, profile selector, rating/favorite/cooked actions | `2026-04-xx-kochwas-phase-3-ui.md` |
+| 4 | Web Search & Admin | SearXNG integration, whitelist admin UI, preview-before-save flow, profile admin | `2026-04-xx-kochwas-phase-4-web-admin.md` |
+| 5 | Advanced Features | Comments, tag editor, cooking-log view, print view, rename/delete, backup/restore ZIP | `2026-04-xx-kochwas-phase-5-advanced.md` |
+| 6 | PWA & Polish | Service worker + offline, Wake-Lock, install prompt, mobile-UX polish, E2E tests, prod Docker image | `2026-04-xx-kochwas-phase-6-pwa.md` |
+
+Each phase produces working software on its own. A phase is complete when all its tasks are checked off, tests pass, and a manual smoke-test on Docker Desktop succeeds.
+
+## Sequencing Rule
+
+Phases are strictly sequential. Do not start Phase N+1 before Phase N is green (tests + smoke test).
+
+## Conventions (all phases)
+
+- **Commits:** each task ends with a single atomic commit. Commit messages follow conventional-commits (`feat:`, `fix:`, `chore:`, `test:`, `docs:`, `refactor:`).
+- **Line endings:** LF only (`.gitattributes` already in place).
+- **Tests:** Vitest for unit/integration, Playwright for E2E. Every pure function gets a unit test first (TDD).
+- **Code style:** Prettier + ESLint using SvelteKit defaults. No emojis in code unless requested.
+- **Dev env:** Local Docker Desktop for the SearXNG container; the SvelteKit app runs via `npm run dev` for fast iteration. Production Docker image built in Phase 6.
+- **TypeScript strict mode** on from the start. No `any` unless justified inline.
diff --git a/docs/superpowers/plans/2026-04-17-kochwas-phase-1-foundations.md b/docs/superpowers/plans/2026-04-17-kochwas-phase-1-foundations.md
new file mode 100644
index 0000000..8a414b4
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-17-kochwas-phase-1-foundations.md
@@ -0,0 +1,1386 @@
+# Kochwas — Phase 1: Foundations
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stand up a SvelteKit + TypeScript project with SQLite/FTS5 schema, a running `/api/health` endpoint, and unit-tested pure-function modules (ingredient-parser, json-ld-extractor, scaler, iso8601-duration) — the building blocks everything else in Kochwas will rely on.
+
+**Architecture:** SvelteKit (Node adapter) + TypeScript strict. `better-sqlite3` as synchronous DB driver. Migration runner driven by ordered SQL files. Pure-function modules in `src/lib/server/` that are exercised via Vitest. SearXNG pulled as Docker container but not integrated yet. Production Docker image deferred to Phase 6 — dev uses `npm run dev`.
+
+**Tech Stack:** Node 22+, SvelteKit 2, TypeScript 5, better-sqlite3, Vitest 2, Zod (for JSON-LD shape validation), Docker Compose (SearXNG), pnpm or npm (npm is assumed below).
+
+**Reference spec:** `docs/superpowers/specs/2026-04-17-kochwas-design.md`
+
+---
+
+## File Structure (created in this phase)
+
+```
+package.json # deps, scripts
+tsconfig.json # strict mode
+svelte.config.js # adapter-node
+vite.config.ts # Vitest config
+.prettierrc # formatting
+.eslintrc.cjs # linting
+.env.example # documented env vars
+.gitignore # node_modules, data, .svelte-kit
+docker-compose.yml # SearXNG for dev
+searxng/settings.yml # SearXNG config
+
+src/app.d.ts # SvelteKit app type
+src/app.html # base HTML shell
+src/routes/+layout.svelte # minimal layout
+src/routes/+page.svelte # placeholder homepage
+src/routes/api/health/+server.ts # health endpoint
+
+src/lib/server/db/index.ts # connection helper
+src/lib/server/db/migrate.ts # migration runner
+src/lib/server/db/migrations/001_init.sql # core schema + FTS5 triggers
+
+src/lib/server/parsers/iso8601-duration.ts # ISO8601 → minutes
+src/lib/server/parsers/ingredient.ts # "200 g Mehl" → structured
+src/lib/server/parsers/json-ld-recipe.ts # HTML → Recipe shape
+src/lib/server/recipes/scaler.ts # scale recipe by factor
+
+src/lib/types.ts # shared types (Recipe, Ingredient, …)
+
+tests/fixtures/chefkoch-carbonara.html # real HTML fixture
+tests/fixtures/emmi-zucchinipuffer.html # real HTML fixture
+tests/unit/iso8601-duration.test.ts
+tests/unit/ingredient.test.ts
+tests/unit/scaler.test.ts
+tests/unit/json-ld-recipe.test.ts
+tests/integration/db.test.ts
+```
+
+---
+
+## Task 1: Scaffold SvelteKit project
+
+**Files:**
+- Create: `package.json`, `svelte.config.js`, `tsconfig.json`, `vite.config.ts`, `src/app.d.ts`, `src/app.html`, `src/routes/+layout.svelte`, `src/routes/+page.svelte`, `.gitignore`, `.prettierrc`, `.eslintrc.cjs`
+
+- [ ] **Step 1: Create package.json**
+
+```json
+{
+ "name": "kochwas",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "lint": "eslint .",
+ "format": "prettier --write ."
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-node": "^5.2.0",
+ "@sveltejs/kit": "^2.8.0",
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
+ "@types/better-sqlite3": "^7.6.11",
+ "@types/node": "^22.9.0",
+ "eslint": "^9.14.0",
+ "prettier": "^3.3.3",
+ "prettier-plugin-svelte": "^3.2.7",
+ "svelte": "^5.1.0",
+ "svelte-check": "^4.0.5",
+ "typescript": "^5.6.3",
+ "vite": "^5.4.10",
+ "vitest": "^2.1.4"
+ },
+ "dependencies": {
+ "better-sqlite3": "^11.5.0",
+ "linkedom": "^0.18.5",
+ "zod": "^3.23.8"
+ }
+}
+```
+
+- [ ] **Step 2: Create tsconfig.json**
+
+```json
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler"
+ }
+}
+```
+
+- [ ] **Step 3: Create svelte.config.js**
+
+```js
+import adapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+export default {
+ preprocess: vitePreprocess(),
+ kit: { adapter: adapter() }
+};
+```
+
+- [ ] **Step 4: Create vite.config.ts**
+
+```ts
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ include: ['tests/**/*.test.ts'],
+ globals: false,
+ environment: 'node'
+ }
+});
+```
+
+- [ ] **Step 5: Create .gitignore, .prettierrc, src/app.html, src/app.d.ts, src/routes/+layout.svelte, src/routes/+page.svelte**
+
+`.gitignore`:
+```
+node_modules/
+.svelte-kit/
+build/
+data/
+.env
+.env.local
+*.log
+```
+
+`.prettierrc`:
+```json
+{
+ "useTabs": false,
+ "singleQuote": true,
+ "trailingComma": "none",
+ "printWidth": 100,
+ "plugins": ["prettier-plugin-svelte"],
+ "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
+}
+```
+
+`src/app.html`:
+```html
+
+
+
+
+
+
+ Kochwas
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
+```
+
+`src/app.d.ts`:
+```ts
+declare global {
+ namespace App {}
+}
+export {};
+```
+
+`src/routes/+layout.svelte`:
+```svelte
+
+```
+
+`src/routes/+page.svelte`:
+```svelte
+Kochwas
+Coming soon.
+```
+
+- [ ] **Step 6: Install & verify**
+
+Run:
+```bash
+npm install
+npx svelte-kit sync
+npm run check
+```
+
+Expected: no type errors.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "feat(scaffold): init SvelteKit + TypeScript project"
+```
+
+---
+
+## Task 2: Set up Vitest with a smoke test
+
+**Files:**
+- Create: `tests/unit/smoke.test.ts`
+
+- [ ] **Step 1: Write smoke test**
+
+```ts
+// tests/unit/smoke.test.ts
+import { describe, it, expect } from 'vitest';
+
+describe('smoke', () => {
+ it('runs', () => {
+ expect(1 + 1).toBe(2);
+ });
+});
+```
+
+- [ ] **Step 2: Run**
+
+```bash
+npm test
+```
+
+Expected: 1 test passes.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/
+git commit -m "test(infra): add vitest smoke test"
+```
+
+---
+
+## Task 3: ISO8601 duration parser
+
+**Files:**
+- Create: `src/lib/server/parsers/iso8601-duration.ts`
+- Test: `tests/unit/iso8601-duration.test.ts`
+
+Purpose: JSON-LD recipes use ISO8601 durations like `PT1H30M`. We need minutes as an integer.
+
+- [ ] **Step 1: Write failing tests**
+
+```ts
+// tests/unit/iso8601-duration.test.ts
+import { describe, it, expect } from 'vitest';
+import { parseIso8601Duration } from '../../src/lib/server/parsers/iso8601-duration';
+
+describe('parseIso8601Duration', () => {
+ it.each([
+ ['PT30M', 30],
+ ['PT1H', 60],
+ ['PT1H30M', 90],
+ ['PT2H15M', 135],
+ ['PT0M', 0],
+ ['P1DT2H', 26 * 60],
+ ['PT90M', 90]
+ ])('parses %s to %i minutes', (input, expected) => {
+ expect(parseIso8601Duration(input)).toBe(expected);
+ });
+
+ it.each([[''], [null], [undefined], ['garbage'], ['30 min'], ['PT']])(
+ 'returns null for invalid input %j',
+ (input) => {
+ expect(parseIso8601Duration(input as string)).toBeNull();
+ }
+ );
+});
+```
+
+- [ ] **Step 2: Run — should fail**
+
+```bash
+npm test -- iso8601-duration
+```
+
+Expected: fails with "parseIso8601Duration is not a function" or similar.
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/lib/server/parsers/iso8601-duration.ts
+const PATTERN = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
+
+export function parseIso8601Duration(input: string | null | undefined): number | null {
+ if (typeof input !== 'string' || input.length === 0) return null;
+ const m = PATTERN.exec(input);
+ if (!m) return null;
+ const [, d, h, min] = m;
+ if (!d && !h && !min && !input.includes('S')) {
+ // Must have at least one component to count; reject bare "PT"/"P"
+ if (input === 'P' || input === 'PT') return null;
+ }
+ const days = d ? parseInt(d, 10) : 0;
+ const hours = h ? parseInt(h, 10) : 0;
+ const mins = min ? parseInt(min, 10) : 0;
+ return days * 24 * 60 + hours * 60 + mins;
+}
+```
+
+- [ ] **Step 4: Run — should pass**
+
+```bash
+npm test -- iso8601-duration
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/server/parsers/iso8601-duration.ts tests/unit/iso8601-duration.test.ts
+git commit -m "feat(parser): add ISO8601 duration parser"
+```
+
+---
+
+## Task 4: Shared types
+
+**Files:**
+- Create: `src/lib/types.ts`
+
+Purpose: Central type definitions shared between server and client.
+
+- [ ] **Step 1: Write types**
+
+```ts
+// src/lib/types.ts
+export type Ingredient = {
+ position: number;
+ quantity: number | null;
+ unit: string | null;
+ name: string;
+ note: string | null;
+ raw_text: string;
+};
+
+export type Step = {
+ position: number;
+ text: string;
+};
+
+export type Recipe = {
+ id: number | null; // null for preview (not saved yet)
+ title: string;
+ description: string | null;
+ source_url: string | null;
+ source_domain: string | null;
+ image_path: string | null;
+ servings_default: number | null;
+ servings_unit: string | null;
+ prep_time_min: number | null;
+ cook_time_min: number | null;
+ total_time_min: number | null;
+ cuisine: string | null;
+ category: string | null;
+ ingredients: Ingredient[];
+ steps: Step[];
+ tags: string[];
+};
+
+export type Profile = {
+ id: number;
+ name: string;
+ avatar_emoji: string | null;
+};
+
+export type AllowedDomain = {
+ id: number;
+ domain: string;
+ display_name: string | null;
+};
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/lib/types.ts
+git commit -m "feat(types): add shared type definitions"
+```
+
+---
+
+## Task 5: Ingredient parser
+
+**Files:**
+- Create: `src/lib/server/parsers/ingredient.ts`
+- Test: `tests/unit/ingredient.test.ts`
+
+Purpose: Convert free-text lines like `"200 g Mehl"` to a structured `{ quantity, unit, name, note, raw_text }`.
+
+- [ ] **Step 1: Write failing tests**
+
+```ts
+// tests/unit/ingredient.test.ts
+import { describe, it, expect } from 'vitest';
+import { parseIngredient } from '../../src/lib/server/parsers/ingredient';
+
+describe('parseIngredient', () => {
+ it.each([
+ ['200 g Mehl', { quantity: 200, unit: 'g', name: 'Mehl' }],
+ ['1 kg Kartoffeln', { quantity: 1, unit: 'kg', name: 'Kartoffeln' }],
+ ['500 ml Milch', { quantity: 500, unit: 'ml', name: 'Milch' }],
+ ['1 TL Salz', { quantity: 1, unit: 'TL', name: 'Salz' }],
+ ['2 EL Olivenöl', { quantity: 2, unit: 'EL', name: 'Olivenöl' }],
+ ['3 Eier', { quantity: 3, unit: null, name: 'Eier' }],
+ ['1/2 Zitrone', { quantity: 0.5, unit: null, name: 'Zitrone' }],
+ ['1,5 l Wasser', { quantity: 1.5, unit: 'l', name: 'Wasser' }]
+ ])('parses %s', (input, expected) => {
+ const parsed = parseIngredient(input);
+ expect(parsed.quantity).toBe(expected.quantity);
+ expect(parsed.unit).toBe(expected.unit);
+ expect(parsed.name).toBe(expected.name);
+ expect(parsed.raw_text).toBe(input);
+ });
+
+ it('handles notes', () => {
+ const p = parseIngredient('200 g Mehl (Type 550)');
+ expect(p.quantity).toBe(200);
+ expect(p.name).toBe('Mehl');
+ expect(p.note).toBe('Type 550');
+ });
+
+ it('falls back to raw_text when unparsable', () => {
+ const p = parseIngredient('etwas frischer Pfeffer');
+ expect(p.quantity).toBeNull();
+ expect(p.unit).toBeNull();
+ expect(p.name).toBe('etwas frischer Pfeffer');
+ expect(p.raw_text).toBe('etwas frischer Pfeffer');
+ });
+
+ it('handles ranges by taking the lower bound', () => {
+ const p = parseIngredient('2-3 Tomaten');
+ expect(p.quantity).toBe(2);
+ expect(p.name).toBe('Tomaten');
+ });
+});
+```
+
+- [ ] **Step 2: Run — should fail**
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/lib/server/parsers/ingredient.ts
+import type { Ingredient } from '$lib/types';
+
+const UNITS = new Set([
+ 'g', 'kg', 'ml', 'l', 'cl', 'dl',
+ 'TL', 'EL', 'Prise', 'Pck.', 'Pkg', 'Becher', 'Stk', 'Stück', 'Bund', 'Tasse', 'Dose'
+]);
+
+const FRACTION_MAP: Record = {
+ '1/2': 0.5, '1/3': 1 / 3, '2/3': 2 / 3, '1/4': 0.25, '3/4': 0.75
+};
+
+function parseQuantity(raw: string): number | null {
+ const trimmed = raw.trim();
+ if (FRACTION_MAP[trimmed] !== undefined) return FRACTION_MAP[trimmed];
+ // Range like "2-3" → take lower bound
+ const rangeMatch = /^(\d+[.,]?\d*)\s*[-–]\s*\d+[.,]?\d*$/.exec(trimmed);
+ if (rangeMatch) {
+ return parseFloat(rangeMatch[1].replace(',', '.'));
+ }
+ const num = parseFloat(trimmed.replace(',', '.'));
+ return Number.isFinite(num) ? num : null;
+}
+
+export function parseIngredient(raw: string, position = 0): Ingredient {
+ const rawText = raw.trim();
+ // Strip note in parentheses
+ let working = rawText;
+ let note: string | null = null;
+ const noteMatch = /\(([^)]+)\)/.exec(working);
+ if (noteMatch) {
+ note = noteMatch[1].trim();
+ working = (working.slice(0, noteMatch.index) + working.slice(noteMatch.index + noteMatch[0].length)).trim();
+ }
+
+ // Match: [quantity] [unit]
+ // Quantity can be a number, fraction, or range
+ const qtyPattern = /^((?:\d+[.,]?\d*(?:\s*[-–]\s*\d+[.,]?\d*)?)|(?:\d+\/\d+))\s+(.+)$/;
+ const qtyMatch = qtyPattern.exec(working);
+ if (!qtyMatch) {
+ return { position, quantity: null, unit: null, name: working, note, raw_text: rawText };
+ }
+ const quantity = parseQuantity(qtyMatch[1]);
+ let rest = qtyMatch[2].trim();
+ let unit: string | null = null;
+ // Check if first token of rest is a unit
+ const firstTokenMatch = /^(\S+)\s+(.+)$/.exec(rest);
+ if (firstTokenMatch && UNITS.has(firstTokenMatch[1])) {
+ unit = firstTokenMatch[1];
+ rest = firstTokenMatch[2].trim();
+ }
+ return { position, quantity, unit, name: rest, note, raw_text: rawText };
+}
+```
+
+- [ ] **Step 4: Run — should pass**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/server/parsers/ingredient.ts tests/unit/ingredient.test.ts
+git commit -m "feat(parser): add ingredient parser"
+```
+
+---
+
+## Task 6: Scaler
+
+**Files:**
+- Create: `src/lib/server/recipes/scaler.ts`
+- Test: `tests/unit/scaler.test.ts`
+
+Purpose: Scale ingredient quantities by a factor, leaving unparsable ingredients unchanged.
+
+- [ ] **Step 1: Write failing tests**
+
+```ts
+// tests/unit/scaler.test.ts
+import { describe, it, expect } from 'vitest';
+import { scaleIngredients, roundQuantity } from '../../src/lib/server/recipes/scaler';
+import type { Ingredient } from '../../src/lib/types';
+
+const mk = (q: number | null, unit: string | null, name: string): Ingredient => ({
+ position: 0, quantity: q, unit, name, note: null, raw_text: ''
+});
+
+describe('roundQuantity', () => {
+ it.each([
+ [0.333333, 0.33],
+ [12.345, 12],
+ [1.55, 1.6],
+ [100.49, 100],
+ [0.5, 0.5]
+ ])('rounds %f to %f', (input, expected) => {
+ expect(roundQuantity(input)).toBe(expected);
+ });
+});
+
+describe('scaleIngredients', () => {
+ it('scales parsed quantities', () => {
+ const scaled = scaleIngredients([mk(200, 'g', 'Mehl'), mk(3, null, 'Eier')], 2);
+ expect(scaled[0].quantity).toBe(400);
+ expect(scaled[1].quantity).toBe(6);
+ });
+
+ it('leaves null quantities alone', () => {
+ const scaled = scaleIngredients([mk(null, null, 'etwas Salz')], 2);
+ expect(scaled[0].quantity).toBeNull();
+ expect(scaled[0].name).toBe('etwas Salz');
+ });
+
+ it('rounds sensibly', () => {
+ const scaled = scaleIngredients([mk(100, 'g', 'Butter')], 1 / 3);
+ expect(scaled[0].quantity).toBe(33);
+ });
+});
+```
+
+- [ ] **Step 2: Run — should fail**
+
+- [ ] **Step 3: Implement**
+
+```ts
+// src/lib/server/recipes/scaler.ts
+import type { Ingredient } from '$lib/types';
+
+export function roundQuantity(q: number): number {
+ if (q === 0) return 0;
+ if (q < 1) return Math.round(q * 100) / 100; // 2 decimals
+ if (q < 10) return Math.round(q * 10) / 10; // 1 decimal
+ return Math.round(q); // integer
+}
+
+export function scaleIngredients(ings: Ingredient[], factor: number): Ingredient[] {
+ if (factor <= 0) throw new Error('factor must be positive');
+ return ings.map((i) => ({
+ ...i,
+ quantity: i.quantity === null ? null : roundQuantity(i.quantity * factor)
+ }));
+}
+```
+
+- [ ] **Step 4: Run — should pass**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/lib/server/recipes/scaler.ts tests/unit/scaler.test.ts
+git commit -m "feat(scaler): add ingredient scaling with sensible rounding"
+```
+
+---
+
+## Task 7: JSON-LD recipe extractor
+
+**Files:**
+- Create: `src/lib/server/parsers/json-ld-recipe.ts`
+- Test: `tests/unit/json-ld-recipe.test.ts`
+- Fixtures: `tests/fixtures/chefkoch-carbonara.html`, `tests/fixtures/emmi-zucchinipuffer.html`
+
+Purpose: Given a full HTML page, find the schema.org/Recipe JSON-LD block and return a normalized Recipe object (without id/image_path — those are filled by the importer in Phase 2).
+
+- [ ] **Step 1: Fetch real HTML fixtures**
+
+Run locally (curl or browser → Save As):
+```bash
+curl -sL -A "Mozilla/5.0" "https://www.chefkoch.de/rezepte/863891191419032/Spaghetti-Carbonara-the-real-one.html" -o tests/fixtures/chefkoch-carbonara.html
+curl -sL -A "Mozilla/5.0" "https://emmikochteinfach.de/zucchinipuffer-einfach-und-schnell/" -o tests/fixtures/emmi-zucchinipuffer.html
+```
+
+If any fetch fails, note it and commit the others. The goal is at least one working fixture per format family.
+
+- [ ] **Step 2: Write failing tests**
+
+```ts
+// tests/unit/json-ld-recipe.test.ts
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { extractRecipeFromHtml } from '../../src/lib/server/parsers/json-ld-recipe';
+
+function load(name: string): string {
+ return readFileSync(join(__dirname, '../fixtures', name), 'utf8');
+}
+
+describe('extractRecipeFromHtml', () => {
+ it('extracts a recipe from Chefkoch HTML', () => {
+ const html = load('chefkoch-carbonara.html');
+ const r = extractRecipeFromHtml(html);
+ expect(r).not.toBeNull();
+ expect(r!.title.toLowerCase()).toContain('carbonara');
+ expect(r!.ingredients.length).toBeGreaterThan(2);
+ expect(r!.steps.length).toBeGreaterThan(0);
+ });
+
+ it('extracts a recipe from Emmi kocht einfach HTML', () => {
+ const html = load('emmi-zucchinipuffer.html');
+ const r = extractRecipeFromHtml(html);
+ expect(r).not.toBeNull();
+ expect(r!.title.toLowerCase()).toContain('zucchini');
+ expect(r!.ingredients.length).toBeGreaterThan(0);
+ });
+
+ it('returns null when no Recipe JSON-LD present', () => {
+ const html = 'no recipe
';
+ expect(extractRecipeFromHtml(html)).toBeNull();
+ });
+});
+```
+
+- [ ] **Step 3: Run — should fail**
+
+- [ ] **Step 4: Implement**
+
+```ts
+// src/lib/server/parsers/json-ld-recipe.ts
+import { parseHTML } from 'linkedom';
+import { parseIso8601Duration } from './iso8601-duration';
+import { parseIngredient } from './ingredient';
+import type { Recipe, Step } from '$lib/types';
+
+type JsonLdNode = Record;
+
+function unwrapGraph(node: unknown): JsonLdNode[] {
+ if (Array.isArray(node)) return node.flatMap(unwrapGraph);
+ if (node && typeof node === 'object') {
+ const obj = node as JsonLdNode;
+ if (obj['@graph']) return unwrapGraph(obj['@graph']);
+ return [obj];
+ }
+ return [];
+}
+
+function isRecipeType(t: unknown): boolean {
+ if (typeof t === 'string') return t === 'Recipe' || t.endsWith('/Recipe');
+ if (Array.isArray(t)) return t.some(isRecipeType);
+ return false;
+}
+
+function toText(v: unknown): string | null {
+ if (typeof v === 'string') return v.trim() || null;
+ if (Array.isArray(v) && v.length > 0) return toText(v[0]);
+ if (v && typeof v === 'object') {
+ const o = v as JsonLdNode;
+ if (typeof o.name === 'string') return o.name.trim();
+ if (typeof o.text === 'string') return o.text.trim();
+ }
+ return null;
+}
+
+function toImagePath(v: unknown): string | null {
+ if (typeof v === 'string') return v;
+ if (Array.isArray(v) && v.length > 0) return toImagePath(v[0]);
+ if (v && typeof v === 'object') {
+ const o = v as JsonLdNode;
+ if (typeof o.url === 'string') return o.url;
+ }
+ return null;
+}
+
+function toStringArray(v: unknown): string[] {
+ if (Array.isArray(v)) return v.map((x) => toText(x)).filter((x): x is string => x !== null);
+ if (typeof v === 'string') return v.split(',').map((s) => s.trim()).filter(Boolean);
+ return [];
+}
+
+function toSteps(v: unknown): Step[] {
+ const out: Step[] = [];
+ const walk = (x: unknown) => {
+ if (Array.isArray(x)) {
+ for (const item of x) walk(item);
+ return;
+ }
+ if (typeof x === 'string') {
+ if (x.trim()) out.push({ position: out.length + 1, text: x.trim() });
+ return;
+ }
+ if (x && typeof x === 'object') {
+ const obj = x as JsonLdNode;
+ if (obj['@type'] === 'HowToSection' && obj.itemListElement) {
+ walk(obj.itemListElement);
+ return;
+ }
+ if (obj['@type'] === 'HowToStep' && typeof obj.text === 'string') {
+ out.push({ position: out.length + 1, text: obj.text.trim() });
+ return;
+ }
+ if (typeof obj.text === 'string') {
+ out.push({ position: out.length + 1, text: obj.text.trim() });
+ }
+ }
+ };
+ walk(v);
+ return out;
+}
+
+function toServings(v: unknown): number | null {
+ if (typeof v === 'number' && Number.isFinite(v)) return Math.trunc(v);
+ if (typeof v === 'string') {
+ const m = /(\d+)/.exec(v);
+ if (m) return parseInt(m[1], 10);
+ }
+ if (Array.isArray(v) && v.length > 0) return toServings(v[0]);
+ return null;
+}
+
+function findRecipeNode(html: string): JsonLdNode | null {
+ const { document } = parseHTML(html);
+ const scripts = document.querySelectorAll('script[type="application/ld+json"]');
+ for (const script of scripts) {
+ const raw = script.textContent;
+ if (!raw) continue;
+ try {
+ const parsed = JSON.parse(raw);
+ for (const node of unwrapGraph(parsed)) {
+ if (isRecipeType(node['@type'])) return node;
+ }
+ } catch {
+ // ignore malformed JSON-LD blocks and continue
+ }
+ }
+ return null;
+}
+
+export function extractRecipeFromHtml(html: string): Recipe | null {
+ const node = findRecipeNode(html);
+ if (!node) return null;
+
+ const title = toText(node.name) ?? '';
+ if (!title) return null;
+
+ const ingredients = Array.isArray(node.recipeIngredient)
+ ? (node.recipeIngredient as unknown[])
+ .map((x, i) => (typeof x === 'string' ? parseIngredient(x, i + 1) : null))
+ .filter((x): x is NonNullable => x !== null)
+ : [];
+
+ const steps = toSteps(node.recipeInstructions);
+ const imageUrl = toImagePath(node.image);
+
+ const prep = parseIso8601Duration(node.prepTime as string | undefined);
+ const cook = parseIso8601Duration(node.cookTime as string | undefined);
+ const total = parseIso8601Duration(node.totalTime as string | undefined);
+
+ const tags = new Set([
+ ...toStringArray(node.recipeCategory),
+ ...toStringArray(node.recipeCuisine),
+ ...toStringArray(node.keywords)
+ ]);
+
+ return {
+ id: null,
+ title,
+ description: toText(node.description),
+ source_url: typeof node.url === 'string' ? node.url : null,
+ source_domain: null, // filled by importer
+ image_path: imageUrl, // here it's still a URL, importer downloads & replaces
+ servings_default: toServings(node.recipeYield),
+ servings_unit: null,
+ prep_time_min: prep,
+ cook_time_min: cook,
+ total_time_min: total,
+ cuisine: toText(node.recipeCuisine),
+ category: toText(node.recipeCategory),
+ ingredients,
+ steps,
+ tags: [...tags]
+ };
+}
+```
+
+- [ ] **Step 5: Run — should pass**
+
+```bash
+npm test -- json-ld-recipe
+```
+
+If a fixture fails, inspect the HTML and adjust the extractor — do not alter the test.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/lib/server/parsers/json-ld-recipe.ts tests/unit/json-ld-recipe.test.ts tests/fixtures/
+git commit -m "feat(parser): add JSON-LD schema.org/Recipe extractor"
+```
+
+---
+
+## Task 8: Database schema and migration runner
+
+**Files:**
+- Create: `src/lib/server/db/migrate.ts`, `src/lib/server/db/index.ts`, `src/lib/server/db/migrations/001_init.sql`
+- Test: `tests/integration/db.test.ts`
+
+- [ ] **Step 1: Write schema file**
+
+```sql
+-- src/lib/server/db/migrations/001_init.sql
+PRAGMA foreign_keys = ON;
+PRAGMA journal_mode = WAL;
+
+CREATE TABLE IF NOT EXISTS profile (
+ id INTEGER PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL,
+ avatar_emoji TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS recipe (
+ id INTEGER PRIMARY KEY,
+ title TEXT NOT NULL,
+ description TEXT,
+ source_url TEXT UNIQUE,
+ source_domain TEXT,
+ image_path TEXT,
+ servings_default INTEGER,
+ servings_unit TEXT,
+ prep_time_min INTEGER,
+ cook_time_min INTEGER,
+ total_time_min INTEGER,
+ cuisine TEXT,
+ category TEXT,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS ingredient (
+ id INTEGER PRIMARY KEY,
+ recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
+ position INTEGER NOT NULL,
+ quantity REAL,
+ unit TEXT,
+ name TEXT NOT NULL,
+ note TEXT,
+ raw_text TEXT
+);
+CREATE INDEX IF NOT EXISTS ix_ingredient_recipe ON ingredient(recipe_id, position);
+
+CREATE TABLE IF NOT EXISTS step (
+ id INTEGER PRIMARY KEY,
+ recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
+ position INTEGER NOT NULL,
+ text TEXT NOT NULL
+);
+CREATE INDEX IF NOT EXISTS ix_step_recipe ON step(recipe_id, position);
+
+CREATE TABLE IF NOT EXISTS tag (
+ id INTEGER PRIMARY KEY,
+ name TEXT UNIQUE NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS recipe_tag (
+ recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
+ tag_id INTEGER NOT NULL REFERENCES tag(id) ON DELETE CASCADE,
+ PRIMARY KEY (recipe_id, tag_id)
+);
+
+CREATE TABLE IF NOT EXISTS rating (
+ recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
+ profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
+ stars INTEGER NOT NULL CHECK (stars BETWEEN 1 AND 5),
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (recipe_id, profile_id)
+);
+
+CREATE TABLE IF NOT EXISTS comment (
+ id INTEGER PRIMARY KEY,
+ recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
+ profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
+ text TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+CREATE TABLE IF NOT EXISTS favorite (
+ recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
+ profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (recipe_id, profile_id)
+);
+
+CREATE TABLE IF NOT EXISTS cooking_log (
+ id INTEGER PRIMARY KEY,
+ recipe_id INTEGER NOT NULL REFERENCES recipe(id) ON DELETE CASCADE,
+ profile_id INTEGER NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
+ cooked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+CREATE INDEX IF NOT EXISTS ix_cooking_log_recipe ON cooking_log(recipe_id, cooked_at);
+
+CREATE TABLE IF NOT EXISTS allowed_domain (
+ id INTEGER PRIMARY KEY,
+ domain TEXT UNIQUE NOT NULL,
+ display_name TEXT,
+ added_by_profile_id INTEGER REFERENCES profile(id),
+ added_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+
+-- FTS5 virtual table
+CREATE VIRTUAL TABLE IF NOT EXISTS recipe_fts USING fts5(
+ title, description, ingredients_concat, tags_concat,
+ content='', tokenize='unicode61 remove_diacritics 2'
+);
+
+-- Triggers to keep FTS in sync with recipe + ingredient + recipe_tag
+-- Strategy: rebuild per-recipe rows via triggers. Simpler than incremental.
+CREATE TRIGGER IF NOT EXISTS trg_recipe_ai AFTER INSERT ON recipe BEGIN
+ INSERT INTO recipe_fts(rowid, title, description, ingredients_concat, tags_concat)
+ VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''), '', '');
+END;
+
+CREATE TRIGGER IF NOT EXISTS trg_recipe_ad AFTER DELETE ON recipe BEGIN
+ INSERT INTO recipe_fts(recipe_fts, rowid, title, description, ingredients_concat, tags_concat)
+ VALUES ('delete', OLD.id, OLD.title, COALESCE(OLD.description, ''), '', '');
+END;
+
+CREATE TRIGGER IF NOT EXISTS trg_recipe_au AFTER UPDATE ON recipe BEGIN
+ INSERT INTO recipe_fts(recipe_fts, rowid, title, description, ingredients_concat, tags_concat)
+ VALUES ('delete', OLD.id, OLD.title, COALESCE(OLD.description, ''), '', '');
+ INSERT INTO recipe_fts(rowid, title, description, ingredients_concat, tags_concat)
+ VALUES (NEW.id, NEW.title, COALESCE(NEW.description, ''),
+ COALESCE((SELECT group_concat(name, ' ') FROM ingredient WHERE recipe_id = NEW.id), ''),
+ COALESCE((SELECT group_concat(t.name, ' ') FROM tag t JOIN recipe_tag rt ON rt.tag_id = t.id WHERE rt.recipe_id = NEW.id), '')
+ );
+END;
+
+-- Ingredient & tag changes trigger full FTS row refresh for the recipe
+CREATE TRIGGER IF NOT EXISTS trg_ingredient_ai AFTER INSERT ON ingredient BEGIN
+ UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.recipe_id;
+END;
+CREATE TRIGGER IF NOT EXISTS trg_ingredient_ad AFTER DELETE ON ingredient BEGIN
+ UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.recipe_id;
+END;
+CREATE TRIGGER IF NOT EXISTS trg_recipe_tag_ai AFTER INSERT ON recipe_tag BEGIN
+ UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.recipe_id;
+END;
+CREATE TRIGGER IF NOT EXISTS trg_recipe_tag_ad AFTER DELETE ON recipe_tag BEGIN
+ UPDATE recipe SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.recipe_id;
+END;
+
+-- Meta table for tracking applied migrations
+CREATE TABLE IF NOT EXISTS schema_migration (
+ name TEXT PRIMARY KEY,
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+);
+```
+
+- [ ] **Step 2: Write migration runner**
+
+```ts
+// src/lib/server/db/migrate.ts
+import Database from 'better-sqlite3';
+import { readdirSync, readFileSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+export function runMigrations(db: Database.Database): void {
+ db.exec(`CREATE TABLE IF NOT EXISTS schema_migration (
+ name TEXT PRIMARY KEY,
+ applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
+ );`);
+
+ const here = dirname(fileURLToPath(import.meta.url));
+ const dir = join(here, 'migrations');
+ const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
+
+ const applied = new Set(
+ db.prepare('SELECT name FROM schema_migration').all().map((r: any) => r.name)
+ );
+
+ for (const file of files) {
+ if (applied.has(file)) continue;
+ const sql = readFileSync(join(dir, file), 'utf8');
+ const tx = db.transaction(() => {
+ db.exec(sql);
+ db.prepare('INSERT INTO schema_migration(name) VALUES (?)').run(file);
+ });
+ tx();
+ }
+}
+```
+
+- [ ] **Step 3: Write db helper**
+
+```ts
+// src/lib/server/db/index.ts
+import Database from 'better-sqlite3';
+import { runMigrations } from './migrate';
+
+let instance: Database.Database | null = null;
+
+export function getDb(path = process.env.DATABASE_PATH ?? './data/kochwas.db'): Database.Database {
+ if (instance) return instance;
+ instance = new Database(path);
+ instance.pragma('journal_mode = WAL');
+ instance.pragma('foreign_keys = ON');
+ runMigrations(instance);
+ return instance;
+}
+
+export function openInMemoryForTest(): Database.Database {
+ const db = new Database(':memory:');
+ db.pragma('foreign_keys = ON');
+ runMigrations(db);
+ return db;
+}
+```
+
+- [ ] **Step 4: Write integration test**
+
+```ts
+// tests/integration/db.test.ts
+import { describe, it, expect } from 'vitest';
+import { openInMemoryForTest } from '../../src/lib/server/db';
+
+describe('db migrations', () => {
+ it('creates all expected tables', () => {
+ const db = openInMemoryForTest();
+ const tables = db
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
+ .all()
+ .map((r: any) => r.name);
+ for (const t of [
+ 'profile', 'recipe', 'ingredient', 'step', 'tag', 'recipe_tag',
+ 'rating', 'comment', 'favorite', 'cooking_log', 'allowed_domain',
+ 'schema_migration'
+ ]) {
+ expect(tables).toContain(t);
+ }
+ });
+
+ it('is idempotent', () => {
+ const db = openInMemoryForTest();
+ // run again manually — should no-op
+ const { runMigrations } = require('../../src/lib/server/db/migrate');
+ runMigrations(db);
+ const migs = db.prepare('SELECT COUNT(*) AS c FROM schema_migration').get() as { c: number };
+ expect(migs.c).toBe(1);
+ });
+
+ it('cascades recipe delete to ingredients and steps', () => {
+ const db = openInMemoryForTest();
+ const id = db
+ .prepare('INSERT INTO recipe(title) VALUES (?) RETURNING id')
+ .get('Test') as { id: number };
+ db.prepare('INSERT INTO ingredient(recipe_id, position, name) VALUES (?, ?, ?)').run(id.id, 1, 'Salz');
+ db.prepare('INSERT INTO step(recipe_id, position, text) VALUES (?, ?, ?)').run(id.id, 1, 'Kochen');
+ db.prepare('DELETE FROM recipe WHERE id = ?').run(id.id);
+ const ings = db.prepare('SELECT COUNT(*) AS c FROM ingredient').get() as { c: number };
+ const steps = db.prepare('SELECT COUNT(*) AS c FROM step').get() as { c: number };
+ expect(ings.c).toBe(0);
+ expect(steps.c).toBe(0);
+ });
+});
+```
+
+- [ ] **Step 5: Update vite.config.ts to include integration tests**
+
+```ts
+// vite.config.ts
+import { sveltekit } from '@sveltejs/kit/vite';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ plugins: [sveltekit()],
+ test: {
+ include: ['tests/**/*.test.ts'],
+ globals: false,
+ environment: 'node',
+ testTimeout: 10_000
+ }
+});
+```
+
+- [ ] **Step 6: Run tests**
+
+```bash
+npm test
+```
+
+Expected: all previous tests + the 3 db tests pass.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A
+git commit -m "feat(db): add SQLite schema, FTS5, migration runner"
+```
+
+---
+
+## Task 9: Health endpoint
+
+**Files:**
+- Create: `src/routes/api/health/+server.ts`
+
+- [ ] **Step 1: Implement**
+
+```ts
+// src/routes/api/health/+server.ts
+import type { RequestHandler } from './$types';
+import { getDb } from '$lib/server/db';
+import { json } from '@sveltejs/kit';
+
+export const GET: RequestHandler = async () => {
+ let dbOk = false;
+ try {
+ const db = getDb();
+ db.prepare('SELECT 1').get();
+ dbOk = true;
+ } catch {
+ dbOk = false;
+ }
+ const status = dbOk ? 200 : 503;
+ return json(
+ { db: dbOk ? 'ok' : 'error', searxng: 'unchecked', version: '0.1.0' },
+ { status }
+ );
+};
+```
+
+- [ ] **Step 2: Smoke-test manually**
+
+Ensure `data/` exists:
+```bash
+mkdir -p data
+npm run dev
+```
+
+In another terminal:
+```bash
+curl -i http://localhost:5173/api/health
+```
+
+Expected: `HTTP/1.1 200 OK` + `{"db":"ok","searxng":"unchecked","version":"0.1.0"}`.
+
+Stop dev server.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/routes/api/health/+server.ts
+git commit -m "feat(api): add /api/health endpoint"
+```
+
+---
+
+## Task 10: SearXNG Docker Compose (dev only)
+
+**Files:**
+- Create: `docker-compose.yml`, `searxng/settings.yml`, `.env.example`
+
+- [ ] **Step 1: Write compose & config**
+
+`docker-compose.yml`:
+```yaml
+services:
+ searxng:
+ image: searxng/searxng:latest
+ ports:
+ - "8888:8080"
+ volumes:
+ - ./searxng:/etc/searxng
+ environment:
+ - BASE_URL=http://localhost:8888/
+ - INSTANCE_NAME=kochwas-search-dev
+ restart: unless-stopped
+```
+
+`searxng/settings.yml` (minimal, allows JSON output):
+```yaml
+use_default_settings: true
+server:
+ secret_key: 'dev-secret-change-in-prod'
+ limiter: false
+ image_proxy: false
+ default_http_headers:
+ X-Content-Type-Options: nosniff
+ X-Download-Options: noopen
+ X-Robots-Tag: noindex, nofollow
+search:
+ formats:
+ - html
+ - json
+ui:
+ default_locale: de
+```
+
+`.env.example`:
+```
+DATABASE_PATH=./data/kochwas.db
+IMAGE_DIR=./data/images
+SEARXNG_URL=http://localhost:8888
+```
+
+- [ ] **Step 2: Smoke-test**
+
+```bash
+docker compose up -d searxng
+sleep 5
+curl -s "http://localhost:8888/search?q=carbonara&format=json" | head -c 300
+docker compose down
+```
+
+Expected: JSON response with search results.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docker-compose.yml searxng/ .env.example
+git commit -m "feat(infra): add SearXNG dev container"
+```
+
+---
+
+## Task 11: Basic homepage with search input
+
+**Files:**
+- Modify: `src/routes/+page.svelte`
+
+Minimal placeholder so we have something to open in the browser. Real search lands in Phase 3.
+
+- [ ] **Step 1: Implement**
+
+```svelte
+
+
+
+
+ Kochwas
+
+ Phase 1: Foundations — Suche folgt in Phase 3.
+
+
+
+```
+
+- [ ] **Step 2: Smoke-test**
+
+```bash
+npm run dev
+```
+
+Open `http://localhost:5173/`, see the search box. Stop dev.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/routes/+page.svelte
+git commit -m "feat(ui): add minimal homepage with search input"
+```
+
+---
+
+## Phase 1 Done-When
+
+- `npm test` green (all unit + integration tests)
+- `npm run check` green (TypeScript clean)
+- `npm run dev` starts the app; `http://localhost:5173/` renders the search input
+- `curl http://localhost:5173/api/health` returns `{"db":"ok",…}` with 200
+- `docker compose up -d searxng` brings SearXNG up; JSON endpoint responds
+- `docker compose down` cleans up
+- All tasks committed on `main`