diff --git a/.cursor/rules/const-arrow-functions.mdc b/.cursor/rules/const-arrow-functions.mdc
new file mode 100644
index 0000000..56a6857
--- /dev/null
+++ b/.cursor/rules/const-arrow-functions.mdc
@@ -0,0 +1,14 @@
+---
+description: Prefer const arrow functions; no function declarations in apps/web TS/TSX
+globs:
+ - apps/web/**/*.ts
+ - apps/web/**/*.tsx
+alwaysApply: false
+---
+
+# Function style (`apps/web`)
+
+- Use **`const name = (…) => { … }`** (or `async` arrows) for named functions, including exports. Do not use **`function name()`** declarations at module scope or nested in source files.
+- **Biome** `complexity/useArrowFunction` rewrites **function expressions** to arrows; it does **not** flag top-level `function` declarations ([Biome discussion #7108](https://github.com/biomejs/biome/discussions/7108)).
+- **`pnpm lint`** (and **`pnpm --filter web lint`**) also runs **`scripts/assert-no-function-declarations.mjs`**, which fails if a declaration line appears in hand-written code under `src/` (excluding `generated/`) or in `vite.config.ts` / `openapi-ts.config.ts`.
+- Generated OpenAPI output under **`src/api/generated/`** is exempt from that script.
diff --git a/.cursor/rules/path-alias-typescript.mdc b/.cursor/rules/path-alias-typescript.mdc
new file mode 100644
index 0000000..f5e8958
--- /dev/null
+++ b/.cursor/rules/path-alias-typescript.mdc
@@ -0,0 +1,13 @@
+---
+description: Path alias @/ vs relative imports in apps/web TypeScript
+globs:
+ - apps/web/**/*.ts
+ - apps/web/**/*.tsx
+alwaysApply: false
+---
+
+# Path imports (`apps/web`)
+
+- **`@/…`** — Use for anything that would otherwise need **`../`** (imports from a parent directory). Configured in `tsconfig.app.json` and Vite.
+- **`./…`** — **Allowed** for same-folder / colocated modules (e.g. `./Page.css` next to a page, `./App` from a test in the same directory). Do not require rewriting these to `@/`.
+- **`../…`** — Disallowed by Biome `style/noRestrictedImports` (`../**`); use `@/` instead.
diff --git a/PLAN.md b/PLAN.md
index 34d5703..ed371b4 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -28,9 +28,10 @@ Tasks and subtasks for building the bread-recipes app (SolidJS + Python REST + O
- [x] **4.1** Scaffold SolidJS + Vite + TypeScript in `apps/web`; **Biome** (lint + format + import organise); Vitest configured; exact dependency versions only; `README.md` for the app.
- [x] **4.2** Generate or synchronise typed API usage from the OpenAPI spec (client/types) so API calls stay strictly typed.
- [x] **4.3** App shell: router, layout, and global styles (clean, minimalist, bread-appropriate palette, responsive).
-- [ ] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click.
-- [ ] **4.5** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id).
-- [ ] **4.6** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate.
+- [x] **4.4** Home page: fetch and list bread recipes with overview + thumbnail; navigate to detail on click.
+- [ ] **4.5** Component library: evaluate options for SolidJS (e.g. **shadcn-solid** with Tailwind and Kobalte primitives vs smaller headless stacks); record the decision; add the chosen tooling and migrate or adopt components on at least one real screen so the pattern is established.
+- [x] **4.6** Recipe page: full recipe content and larger image; deep-linkable route (e.g. by id).
+- [ ] **4.7** MSW for tests; knip configured; Vitest coverage at 100% with a CI gate.
## 5. Contract testing (Pact)
diff --git a/apps/api/app/main.py b/apps/api/app/main.py
index d8aa204..c5184d2 100644
--- a/apps/api/app/main.py
+++ b/apps/api/app/main.py
@@ -3,6 +3,7 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
@@ -34,6 +35,17 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ ],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
@app.exception_handler(RecipeNotFoundError)
def recipe_not_found_handler(_request: Request, _exc: RecipeNotFoundError) -> JSONResponse:
diff --git a/apps/web/biome.json b/apps/web/biome.json
index 4688bc1..0db319f 100644
--- a/apps/web/biome.json
+++ b/apps/web/biome.json
@@ -25,7 +25,18 @@
"useExplicitType": "error"
},
"style": {
- "noDefaultExport": "error"
+ "noDefaultExport": "error",
+ "noRestrictedImports": {
+ "level": "error",
+ "options": {
+ "patterns": [
+ {
+ "group": ["../**"],
+ "message": "Use the @/ path alias (see tsconfig paths) instead of ../ — same-folder imports like ./file are fine."
+ }
+ ]
+ }
+ }
}
}
},
diff --git a/apps/web/package.json b/apps/web/package.json
index ff10ab7..401043c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
- "lint": "biome check .",
+ "lint": "biome check . && node scripts/assert-no-function-declarations.mjs",
"lint:fix": "biome check --write .",
"test": "vitest run",
"test:watch": "vitest",
diff --git a/apps/web/scripts/assert-no-function-declarations.mjs b/apps/web/scripts/assert-no-function-declarations.mjs
new file mode 100644
index 0000000..30b042a
--- /dev/null
+++ b/apps/web/scripts/assert-no-function-declarations.mjs
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+/**
+ * Biome's useArrowFunction does not flag top-level `function` declarations (see https://github.com/biomejs/biome/discussions/7108).
+ * This check enforces `const name = (...) =>` for named functions in hand-written TS/TSX.
+ * Generated by Cursor.
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+
+const rootDir = path.join(import.meta.dirname, '..');
+/** Top-level / named `function` declarations (not `typeof x === 'function'`). */
+const declarationLine =
+ /^\s*(?:export\s+(?:default\s+)?(?:async\s+)?|async\s+)?function\s*\*?\s*(?:[$\w]|\()/;
+
+function walkTsFiles(dir, out) {
+ for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, ent.name);
+ if (ent.isDirectory()) {
+ if (ent.name === 'generated') {
+ continue;
+ }
+ walkTsFiles(p, out);
+ } else if (/\.(ts|tsx)$/.test(ent.name)) {
+ out.push(p);
+ }
+ }
+}
+
+const files = [];
+walkTsFiles(path.join(rootDir, 'src'), files);
+
+for (const base of ['vite.config.ts', 'openapi-ts.config.ts']) {
+ const p = path.join(rootDir, base);
+ if (fs.existsSync(p)) {
+ files.push(p);
+ }
+}
+
+let failed = false;
+for (const file of files) {
+ const lines = fs.readFileSync(file, 'utf8').split('\n');
+ lines.forEach((line, i) => {
+ const trimmed = line.trimStart();
+ if (trimmed.startsWith('//') || trimmed.startsWith('*')) {
+ return;
+ }
+ if (declarationLine.test(line)) {
+ console.error(
+ `${path.relative(rootDir, file)}:${i + 1}: use \`const name = ...\` instead of a \`function\` declaration`,
+ );
+ failed = true;
+ }
+ });
+}
+
+if (failed) {
+ process.exit(1);
+}
diff --git a/apps/web/src/App.test.tsx b/apps/web/src/App.test.tsx
index f827428..097142b 100644
--- a/apps/web/src/App.test.tsx
+++ b/apps/web/src/App.test.tsx
@@ -1,7 +1,16 @@
import { render, screen } from '@solidjs/testing-library';
-import { describe, expect, it } from 'vitest';
+import { describe, expect, it, vi } from 'vitest';
import { App } from './App';
+vi.mock('@/api', () => ({
+ listRecipes: vi.fn().mockResolvedValue({
+ data: [],
+ error: undefined,
+ request: new Request('http://127.0.0.1:8000/recipes'),
+ response: new Response(),
+ }),
+}));
+
describe('App', () => {
it('renders the main heading', () => {
render(() => );
diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts
index 2c18b38..245b886 100644
--- a/apps/web/src/api/client.ts
+++ b/apps/web/src/api/client.ts
@@ -5,7 +5,7 @@ import {
createConfig,
} from './generated/client';
-function resolveBaseUrl(): string {
+const resolveBaseUrl = (): string => {
const fromEnv = import.meta.env.VITE_API_BASE_URL;
if (fromEnv !== undefined && fromEnv !== '') {
return fromEnv;
@@ -14,7 +14,7 @@ function resolveBaseUrl(): string {
return 'http://127.0.0.1:8000';
}
return '';
-}
+};
/** Shared fetch client for generated SDK calls (base URL from `VITE_API_BASE_URL`, or local API in dev). */
export const apiClient: Client = createClient(
diff --git a/apps/web/src/components/RecipeCard.tsx b/apps/web/src/components/RecipeCard.tsx
new file mode 100644
index 0000000..fda68ce
--- /dev/null
+++ b/apps/web/src/components/RecipeCard.tsx
@@ -0,0 +1,36 @@
+import { A } from '@solidjs/router';
+import type { JSX } from 'solid-js';
+import type { RecipeSummary } from '@/api';
+
+export const RecipeCard = ({
+ id,
+ imageUrl,
+ title,
+ summary,
+}: RecipeSummary): JSX.Element => (
+
+
+
+
+
+ {title}
+
+
{summary}
+
+
+
+);
diff --git a/apps/web/src/components/recipe-detail/RecipeDetailBody.tsx b/apps/web/src/components/recipe-detail/RecipeDetailBody.tsx
new file mode 100644
index 0000000..0c018bf
--- /dev/null
+++ b/apps/web/src/components/recipe-detail/RecipeDetailBody.tsx
@@ -0,0 +1,28 @@
+import type { JSX } from 'solid-js';
+import type { RecipeDetail } from '@/api';
+import { RecipeDetailHeader } from './RecipeDetailHeader';
+import { RecipeDetailHero } from './RecipeDetailHero';
+import { RecipeDetailIngredients } from './RecipeDetailIngredients';
+import { RecipeDetailSteps } from './RecipeDetailSteps';
+
+export const RecipeDetailBody = ({
+ title,
+ summary,
+ prepTimeMinutes,
+ bakeTimeMinutes,
+ imageUrlLarge,
+ ingredients,
+ steps,
+}: RecipeDetail): JSX.Element => (
+ <>
+
+
+
+
+ >
+);
diff --git a/apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx b/apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx
new file mode 100644
index 0000000..c9b4448
--- /dev/null
+++ b/apps/web/src/components/recipe-detail/RecipeDetailHeader.tsx
@@ -0,0 +1,32 @@
+import type { JSX } from 'solid-js';
+import { Show } from 'solid-js';
+import type { RecipeDetail } from '@/api';
+
+type RecipeDetailHeaderProps = Pick<
+ RecipeDetail,
+ 'title' | 'summary' | 'prepTimeMinutes' | 'bakeTimeMinutes'
+>;
+
+export const RecipeDetailHeader = ({
+ title,
+ summary,
+ prepTimeMinutes,
+ bakeTimeMinutes,
+}: RecipeDetailHeaderProps): JSX.Element => (
+
+);
diff --git a/apps/web/src/components/recipe-detail/RecipeDetailHero.tsx b/apps/web/src/components/recipe-detail/RecipeDetailHero.tsx
new file mode 100644
index 0000000..23cc94c
--- /dev/null
+++ b/apps/web/src/components/recipe-detail/RecipeDetailHero.tsx
@@ -0,0 +1,20 @@
+import type { JSX } from 'solid-js';
+import type { RecipeDetail } from '@/api';
+
+type RecipeDetailHeroProps = Pick;
+
+export const RecipeDetailHero = ({
+ imageUrlLarge,
+}: RecipeDetailHeroProps): JSX.Element => (
+
+

+
+);
diff --git a/apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx b/apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx
new file mode 100644
index 0000000..1b9a508
--- /dev/null
+++ b/apps/web/src/components/recipe-detail/RecipeDetailIngredients.tsx
@@ -0,0 +1,23 @@
+import type { JSX } from 'solid-js';
+import { For } from 'solid-js';
+import type { RecipeDetail } from '@/api';
+
+type RecipeDetailIngredientsProps = Pick;
+
+export const RecipeDetailIngredients = ({
+ ingredients,
+}: RecipeDetailIngredientsProps): JSX.Element => (
+
+
+ Ingredients
+
+
+
+ {(line: string): JSX.Element => - {line}
}
+
+
+
+);
diff --git a/apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx b/apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx
new file mode 100644
index 0000000..e5586e6
--- /dev/null
+++ b/apps/web/src/components/recipe-detail/RecipeDetailSteps.tsx
@@ -0,0 +1,18 @@
+import type { JSX } from 'solid-js';
+import { For } from 'solid-js';
+import type { RecipeDetail } from '@/api';
+
+type RecipeDetailStepsProps = Pick;
+
+export const RecipeDetailSteps = ({
+ steps,
+}: RecipeDetailStepsProps): JSX.Element => (
+
+
+ Steps
+
+
+ {(step: string): JSX.Element => - {step}
}
+
+
+);
diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx
index 6ce0956..72fe88a 100644
--- a/apps/web/src/index.tsx
+++ b/apps/web/src/index.tsx
@@ -2,7 +2,7 @@
import type { JSX } from 'solid-js';
import { render } from 'solid-js/web';
import './index.css';
-import { App } from './App.tsx';
+import { App } from './App';
const root: HTMLElement | null = document.getElementById('root');
if (!root) {
diff --git a/apps/web/src/pages/Home.test.tsx b/apps/web/src/pages/Home.test.tsx
new file mode 100644
index 0000000..34279b9
--- /dev/null
+++ b/apps/web/src/pages/Home.test.tsx
@@ -0,0 +1,71 @@
+import { MemoryRouter, Route } from '@solidjs/router';
+import { render, screen } from '@solidjs/testing-library';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { RecipeSummary } from '@/api';
+import { listRecipes } from '@/api';
+import { Home } from './Home';
+
+const renderHome = (): ReturnType =>
+ render(() => (
+
+
+
+ ));
+
+vi.mock('@/api', async (importOriginal) => {
+ const mod = await importOriginal();
+ return {
+ ...mod,
+ listRecipes: vi.fn(),
+ };
+});
+
+const listOk = (
+ data: RecipeSummary[],
+): {
+ data: RecipeSummary[];
+ error: undefined;
+ request: Request;
+ response: Response;
+} => ({
+ data,
+ error: undefined,
+ request: new Request('http://127.0.0.1:8000/recipes'),
+ response: new Response(),
+});
+
+describe('Home', () => {
+ beforeEach(() => {
+ vi.mocked(listRecipes).mockReset();
+ });
+
+ it('shows empty state when the API returns no recipes', async () => {
+ vi.mocked(listRecipes).mockResolvedValue(listOk([]));
+ renderHome();
+ expect(await screen.findByText(/No recipes to show yet/i)).toBeTruthy();
+ });
+
+ it('renders linked recipe cards when the API returns recipes', async () => {
+ vi.mocked(listRecipes).mockResolvedValue(
+ listOk([
+ {
+ id: 'sourdough-1',
+ title: 'Seeded sourdough',
+ summary: 'Overnight ferment',
+ imageUrl: 'https://example.com/thumb.jpg',
+ },
+ ]),
+ );
+ renderHome();
+ expect(await screen.findByText('Seeded sourdough')).toBeTruthy();
+ const link = screen.getByRole('link', { name: /Seeded sourdough/i });
+ expect(link.getAttribute('href')).toBe('/recipes/sourdough-1');
+ });
+
+ it('shows an alert when the loader throws', async () => {
+ vi.mocked(listRecipes).mockRejectedValue(new Error('Bad gateway'));
+ renderHome();
+ const alert = await screen.findByRole('alert');
+ expect(alert.textContent).toContain('Bad gateway');
+ });
+});
diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx
index 99fc9ea..79f5f1f 100644
--- a/apps/web/src/pages/Home.tsx
+++ b/apps/web/src/pages/Home.tsx
@@ -1,11 +1,55 @@
import type { JSX } from 'solid-js';
+import { createResource, For, Show } from 'solid-js';
+import type { RecipeSummary } from '@/api';
+import { listRecipes } from '@/api';
+import { RecipeCard } from '@/components/RecipeCard';
import './Page.css';
-export const Home = (): JSX.Element => (
-
-
- Browse sourdough and yeasted recipes from the API. List and detail views
- follow in the next milestones.
-
-
-);
+export const Home = (): JSX.Element => {
+ const [recipes] = createResource(() =>
+ listRecipes({ throwOnError: true }).then(({ data }) => data ?? []),
+ );
+
+ return (
+
+
+ Recipes
+
+
+ Browse sourdough and yeasted breads from the API. Select a recipe for
+ full ingredients and steps.
+
+
+
+
+ Loading recipes…
+
+
+
+
+ {(err: unknown) => (
+
+ {err instanceof Error ? err.message : String(err)}
+
+ )}
+
+
+
+ 0}
+ fallback={
+
+ No recipes to show yet. Try again later.
+
+ }
+ >
+
+
+ {(recipe: RecipeSummary) => }
+
+
+
+
+
+ );
+};
diff --git a/apps/web/src/pages/Page.css b/apps/web/src/pages/Page.css
index 0c86750..95a1b2e 100644
--- a/apps/web/src/pages/Page.css
+++ b/apps/web/src/pages/Page.css
@@ -32,3 +32,221 @@
background: var(--code-bg);
color: var(--text-h);
}
+
+.home-lede {
+ margin-bottom: 1.25rem;
+}
+
+.recipe-list-status,
+.recipe-list-empty {
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.recipe-list-error {
+ color: var(--accent-hover);
+ margin: 0;
+ padding: 0.75rem 1rem;
+ border-radius: 8px;
+ border: 1px solid var(--accent-border);
+ background: var(--accent-bg);
+}
+
+.recipe-grid {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.recipe-grid-item {
+ margin: 0;
+}
+
+.recipe-card {
+ display: grid;
+ grid-template-columns: minmax(0, 120px) minmax(0, 1fr);
+ gap: 0.85rem;
+ align-items: stretch;
+ padding: 0.65rem;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--social-bg);
+ box-shadow: var(--shadow);
+ text-decoration: none;
+ color: inherit;
+ transition:
+ border-color 0.15s ease,
+ box-shadow 0.15s ease;
+}
+
+.recipe-card:hover {
+ border-color: var(--accent-border);
+ box-shadow:
+ rgba(45, 70, 85, 0.1) 0 12px 28px -8px,
+ rgba(40, 55, 65, 0.06) 0 4px 10px -4px;
+}
+
+@media (prefers-color-scheme: dark) {
+ .recipe-card:hover {
+ box-shadow:
+ rgba(0, 0, 0, 0.35) 0 12px 28px -8px,
+ rgba(0, 0, 0, 0.2) 0 4px 10px -4px;
+ }
+}
+
+.recipe-card:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.recipe-card-media {
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--border);
+ aspect-ratio: 4 / 3;
+ min-height: 0;
+}
+
+.recipe-card-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.recipe-card-body {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ justify-content: center;
+}
+
+.recipe-card-title {
+ font-family: var(--heading);
+ font-weight: 500;
+ font-size: 1.05rem;
+ color: var(--accent);
+ margin: 0;
+ letter-spacing: -0.02em;
+}
+
+.recipe-card:hover .recipe-card-title {
+ color: var(--accent-hover);
+}
+
+.recipe-card-summary {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.45;
+ color: var(--text-muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+@media (min-width: 480px) {
+ .recipe-card {
+ grid-template-columns: minmax(0, 140px) minmax(0, 1fr);
+ gap: 1rem;
+ padding: 0.75rem;
+ }
+}
+
+/* Recipe detail (§4.6) */
+.recipe-detail-back {
+ margin: 0 0 1rem;
+}
+
+.recipe-detail-back-link {
+ font-size: 0.95rem;
+ color: var(--accent);
+ text-decoration: none;
+}
+
+.recipe-detail-back-link:hover {
+ color: var(--accent-hover);
+ text-decoration: underline;
+}
+
+.recipe-detail-header {
+ margin-bottom: 1rem;
+}
+
+.recipe-detail-title {
+ margin-bottom: 0.5rem;
+}
+
+.recipe-detail-summary {
+ margin-bottom: 0.75rem;
+}
+
+.recipe-detail-times {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem 1.25rem;
+ margin: 0;
+ font-size: 0.9rem;
+ color: var(--text-muted);
+}
+
+.recipe-detail-time {
+ margin: 0;
+}
+
+.recipe-detail-hero {
+ margin: 0 0 1.5rem;
+ border-radius: 10px;
+ overflow: hidden;
+ background: var(--border);
+ aspect-ratio: 3 / 2;
+ max-height: min(60vh, 420px);
+}
+
+.recipe-detail-image {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+
+.recipe-detail-section {
+ margin-bottom: 1.5rem;
+}
+
+.recipe-detail-h3 {
+ font-family: var(--heading);
+ font-weight: 500;
+ font-size: 1.15rem;
+ color: var(--text-h);
+ margin: 0 0 0.65rem;
+ letter-spacing: -0.02em;
+}
+
+.recipe-detail-list {
+ margin: 0;
+ padding-left: 1.25rem;
+ color: var(--text);
+ line-height: 1.55;
+}
+
+.recipe-detail-list-ingredients {
+ list-style: disc;
+}
+
+.recipe-detail-list-steps {
+ padding-left: 1.5rem;
+}
+
+.recipe-detail-list li {
+ margin-bottom: 0.4rem;
+}
+
+.recipe-detail-list-steps li::marker {
+ font-weight: 500;
+ color: var(--accent);
+}
diff --git a/apps/web/src/pages/RecipePage.tsx b/apps/web/src/pages/RecipePage.tsx
index 8828d49..4f33c48 100644
--- a/apps/web/src/pages/RecipePage.tsx
+++ b/apps/web/src/pages/RecipePage.tsx
@@ -1,19 +1,54 @@
-import { useParams } from '@solidjs/router';
+import { A, useParams } from '@solidjs/router';
import type { JSX } from 'solid-js';
+import { createResource, Show } from 'solid-js';
+import type { RecipeDetail } from '@/api';
+import { getRecipeById } from '@/api';
+import { RecipeDetailBody } from '@/components/recipe-detail/RecipeDetailBody';
import './Page.css';
export const RecipePage = (): JSX.Element => {
const params = useParams<{ id: string }>();
+ const [recipe] = createResource(
+ () => params.id,
+ async (id: string | undefined) => {
+ if (!id) {
+ throw new Error('Missing recipe id.');
+ }
+ const { data } = await getRecipeById({
+ path: { recipe_id: id },
+ throwOnError: true,
+ });
+ return data;
+ },
+ );
return (
-
-
- Recipe
-
-
- Full ingredients, steps, and imagery for recipe{' '}
- {params.id} arrive in PLAN §4.5.
+
+
+
+ ← All recipes
+
-
+
+
+
+ Loading recipe…
+
+
+
+
+ {(err: unknown) => (
+
+ {err instanceof Error ? err.message : String(err)}
+
+ )}
+
+
+
+
+ {(detail: RecipeDetail) => }
+
+
+
);
};
diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json
index a61ed6b..2b089a2 100644
--- a/apps/web/tsconfig.app.json
+++ b/apps/web/tsconfig.app.json
@@ -14,6 +14,10 @@
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
"jsx": "preserve",
"jsxImportSource": "solid-js",
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 2c02513..ed700a7 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -1,8 +1,17 @@
///
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
import solid from 'vite-plugin-solid';
+const __dirname: string = path.dirname(fileURLToPath(import.meta.url));
+
export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
plugins: [solid()],
test: {
environment: 'jsdom',
diff --git a/packages/openapi/package.json b/packages/openapi/package.json
index 4c00887..04d5ac1 100644
--- a/packages/openapi/package.json
+++ b/packages/openapi/package.json
@@ -8,6 +8,7 @@
},
"devDependencies": {
"@redocly/cli": "1.31.0",
- "cross-env": "10.1.0"
+ "cross-env": "10.1.0",
+ "yaml": "2.8.3"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6834187..a19585d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -43,13 +43,13 @@ importers:
version: 5.9.3
vite:
specifier: 8.0.3
- version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)
+ version: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)
vite-plugin-solid:
specifier: 2.11.11
- version: 2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1))
+ version: 2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3))
vitest:
specifier: 4.1.2
- version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1))
+ version: 4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3))
packages/openapi:
devDependencies:
@@ -59,6 +59,9 @@ importers:
cross-env:
specifier: 10.1.0
version: 10.1.0
+ yaml:
+ specifier: 2.8.3
+ version: 2.8.3
packages:
@@ -2020,6 +2023,11 @@ packages:
resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==}
engines: {node: '>= 6'}
+ yaml@2.8.3:
+ resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@@ -2647,13 +2655,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1))':
+ '@vitest/mocker@4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3))':
dependencies:
'@vitest/spy': 4.1.2
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)
'@vitest/pretty-format@4.1.2':
dependencies:
@@ -3823,7 +3831,7 @@ snapshots:
util-deprecate@1.0.2: {}
- vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)):
+ vite-plugin-solid@2.11.11(@testing-library/jest-dom@6.9.1)(solid-js@1.9.12)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)):
dependencies:
'@babel/core': 7.29.0
'@types/babel__core': 7.20.5
@@ -3831,14 +3839,14 @@ snapshots:
merge-anything: 5.1.7
solid-js: 1.9.12
solid-refresh: 0.6.3(solid-js@1.9.12)
- vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)
- vitefu: 1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1))
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)
+ vitefu: 1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3))
optionalDependencies:
'@testing-library/jest-dom': 6.9.1
transitivePeerDependencies:
- supports-color
- vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1):
+ vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -3849,18 +3857,19 @@ snapshots:
'@types/node': 24.12.0
fsevents: 2.3.3
jiti: 2.6.1
+ yaml: 2.8.3
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
- vitefu@1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)):
+ vitefu@1.1.3(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)):
optionalDependencies:
- vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)
- vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)):
+ vitest@4.1.2(@opentelemetry/api@1.9.0)(@types/node@24.12.0)(jsdom@29.0.1)(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)):
dependencies:
'@vitest/expect': 4.1.2
- '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1))
+ '@vitest/mocker': 4.1.2(vite@8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3))
'@vitest/pretty-format': 4.1.2
'@vitest/runner': 4.1.2
'@vitest/snapshot': 4.1.2
@@ -3877,7 +3886,7 @@ snapshots:
tinyexec: 1.0.4
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
- vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)
+ vite: 8.0.3(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@types/node@24.12.0)(jiti@2.6.1)(yaml@2.8.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -3947,6 +3956,8 @@ snapshots:
yaml@1.10.3: {}
+ yaml@2.8.3: {}
+
yargs-parser@20.2.9: {}
yargs@17.0.1: