Compare commits

..

No commits in common. "b81ec7c81c921bb30f2e10041f9196919f874c8c" and "476ca0b0c29ce87442cfda1126d299359ea5924c" have entirely different histories.

165 changed files with 1415 additions and 11194 deletions

View File

@ -1,20 +0,0 @@
# Server configuration
PORT=3000
DATABASE_PATH=data/recipes.db
NODE_ENV=development
# CORS configuration
# Set to specific origin in production (e.g., https://your-frontend.com)
# Use * for development only
ALLOWED_ORIGIN=*
# Rate limiting (import endpoint)
IMPORT_RATE_LIMIT_WINDOW_MS=60000
IMPORT_RATE_LIMIT_MAX_REQUESTS=10
# API authentication (optional but recommended for production)
# Set a strong random string if API_KEY is defined; write endpoints will require it
#API_KEY=
# Logging
LOG_LEVEL=info

View File

@ -1,70 +0,0 @@
# T06 — Styling Guardrails + Lightweight Governance
Date: 2026-03-27
Scope: `recipe-manager/frontend`
## Why this exists
The styling stabilization pass established a token + primitive contract. This governance doc keeps that contract from drifting back into mixed styling modes.
## Canonical styling contract (short version)
1. **Tokens are defined in one place:** `src/styles/tokens.css`
2. **Build UI from shared primitives first:** `src/components/ui/primitives.tsx` + `ui-*` classes from `src/index.css`
3. **Use tokenized values in classes/styles:** `var(--...)` or Tailwind theme aliases mapped to tokens
4. **Do not add new raw Tailwind palette utilities** (e.g. `bg-blue-500`, `text-slate-600`) in stabilized shared surfaces
5. **Do not hardcode hex colors in `className`** (e.g. `bg-[#1f2937]`) for app UI
Reference docs:
- `./styling-token-contract.md`
- `./ui-primitive-contract.md`
- `./t05-legacy-style-debt-cleanup.md`
## Lightweight automated guardrail
A scoped check is available:
```bash
cd frontend
npm run style:guardrails
```
Current guard scope (intentional, low-risk):
- `src/App.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/components/ui/primitives.tsx`
What it flags:
- direct palette utility classes (`bg-blue-*`, `text-slate-*`, etc.)
- hex colors in arbitrary utility classes (`bg-[#...]`, `text-[#...]`, etc.)
If violations are found in guarded files, the script exits non-zero.
## Contributor checklist (copy into PR template/comments as needed)
- [ ] New UI uses `Ui*` primitives or existing `ui-*` classes where possible
- [ ] Any new visual token was added to `tokens.css` first
- [ ] No new hardcoded palette utility classes in stabilized shell/shared primitive areas
- [ ] No new hardcoded hex colors in class utilities for app surfaces
- [ ] `npm run style:guardrails` passes
- [ ] `npm run lint` passes
## Review heuristics for agents/contributors
When touching frontend UI, quickly answer:
1. "Can this use an existing `Ui*` primitive?"
2. "If I need a new color/radius/shadow, did I add a semantic token first?"
3. "Am I introducing a second styling path that future edits will copy?"
If the answer to (3) is yes, pause and refactor to the contract.
## Intentional limitations (for now)
- The guard script is **scoped**, not repo-wide, because existing pages still contain known legacy palette usage (tracked in T01/T04).
- The check is intentionally simple regex-based (fast, no lint plugin churn).
- This is not wired into heavy CI yet; it is run locally and can be added to future CI once high-drift pages are migrated.
## Expansion path after T04/T07
As additional pages are stabilized, add them to `frontend/scripts/style-guardrails.mjs` scope to progressively tighten enforcement without breaking current workflows.

View File

@ -1,184 +0,0 @@
# T01 — Styling Inventory + Drift Map
Date: 2026-03-27
Project: `recipe-manager/frontend`
Task: **T01 — Styling Inventory + Drift Map**
## Scope audited
- Token sources: `src/styles/tokens.css`, `src/theme.ts`, `frontend/tailwind.config.js`
- Shared styling layer: `src/index.css` (`ui-*` primitives)
- Legacy stylesheet: `src/App.css`
- App shell + representative screens/components:
- `src/App.tsx`
- `src/pages/RecipeListPage.tsx`
- `src/pages/RecipeDetailPage.tsx`
- `src/pages/CookModePage.tsx`
- `src/pages/ImportUrlPage.tsx`
- `src/pages/NotFoundPage.tsx`
- `src/components/RecipeCard.tsx`
- `src/components/RecipeForm.tsx`
- `src/components/TagSelector.tsx`
- `src/components/Toast.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/components/ErrorBoundary.tsx`
---
## 1) Where styling primitives currently live
### Canonical CSS token surface (already strong)
- **`src/styles/tokens.css`**
- semantic color, typography, spacing, radius, shadow, focus, gradients
- includes dark-mode token overrides
### Utility-class primitives (already present)
- **`src/index.css` @layer components** defines:
- `.ui-page`, `.ui-section`, `.ui-card`
- `.ui-btn`, `.ui-btn-primary`, `.ui-btn-secondary`
- `.ui-input`, `.ui-textarea`, `.ui-select`
- `.ui-chip`, `.ui-badge`
- Also contains global focus-visible behavior and utility legacy classes (`.card`, `.shadow-card`, `animate-slide-in`)
### Tailwind token mapping
- **`frontend/tailwind.config.js`** maps many Tailwind theme keys to CSS vars (good bridge)
### Parallel TS token layer (drift risk)
- **`src/theme.ts`** duplicates many token values from `tokens.css`
- `colors`, `typography`, `spacing`, `radius`, `shadows`, `componentStyles`
### Legacy stylesheet
- **`src/App.css`** contains Vite/demo-era nested selectors (`#next-steps`, `.hero`, `.counter`, etc.)
- not imported by `main.tsx` or other src files in current app
---
## 2) Systems currently being mixed
1. **UI primitives + token vars** (`ui-*`, `var(--...)`)
2. **Raw Tailwind palette utilities** (`bg-blue-*`, `text-slate-*`, `border-gray-*`, etc.)
3. **Inline style objects via `theme.ts`** (`style={{ borderRadius: radius.lg }}`, `boxShadow: shadows.card`)
4. **One-off arbitrary values** (`hover:[box-shadow:var(--shadow-hover)]`, `shadow-[...]`)
5. **Legacy class debt** (`App.css`, and old helper classes in `index.css`)
Result: there is no enforced single styling path; authors can pick multiple parallel approaches in the same file.
---
## 3) Inventory by file (top-level pages + shared components)
Legend:
- **A** = ui-* primitives + var tokens (preferred direction)
- **B** = raw Tailwind palette utilities (non-tokenized drift)
- **C** = inline `theme.ts` style objects
- **D** = legacy/older pattern
### App shell
- `src/App.tsx` → **B-heavy**
- Header/nav/footer use many `blue/slate/gray` classes and custom focus ring colors
- Does not use `ui-page`, `ui-section`, `ui-btn` contract
### Pages
- `src/pages/RecipeListPage.tsx` → **A + C mix (plus some B)**
- strong `ui-*` adoption
- frequent inline radius/style injections from `theme.ts`
- `src/pages/RecipeDetailPage.tsx` → **A + B + C mixed**
- uses many `ui-*` classes but also many non-token Tailwind palette classes and inline styles
- `src/pages/CookModePage.tsx` → **B-only (high drift)**
- almost entirely gray/blue/green/red Tailwind palette utilities
- no `ui-*` shell/primitives
- `src/pages/ImportUrlPage.tsx` → **B + C (high drift)**
- heavily uses slate/blue/indigo/green palette classes
- inline radius/progress width styles
- `src/pages/NotFoundPage.tsx` → **B-only**
### Shared components
- `src/components/RecipeForm.tsx` → **A-dominant**
- `src/components/TagSelector.tsx` → **A-dominant + tiny C**
- `src/components/RecipeCard.tsx` → **A + C mixed**
- `src/components/Toast.tsx` → **C-dominant**
- visual appearance mostly from `theme.ts` inline styles
- `src/components/MissionControlPanel.tsx` → **B-only**
- `src/components/ErrorBoundary.tsx` → **B-only**
### Approximate drift signal (quick scan)
- Highest raw palette concentration: `CookModePage`, `ImportUrlPage`, `App.tsx`, `RecipeDetailPage`
- Highest inline-style concentration: `RecipeListPage`, `RecipeCard`, `Toast`
---
## 4) Representative drift hotspots
1. **`App.tsx` (app shell)**
- Entire global frame (header/nav/footer) is on blue/slate palette utilities
- creates visual disconnect from tokenized warm palette used in recipe surfaces
2. **`CookModePage.tsx`**
- Separate visual language (blue/gray success/error blocks) and no `ui-*` primitives
- likely to regress independently if token theme changes
3. **`ImportUrlPage.tsx`**
- Similar drift pattern to CookModePage; raw utility palette + custom gradients
4. **`RecipeDetailPage.tsx`**
- Mixed approach in same component: token vars + ui-* + raw palette + inline styles
- hard to predict and maintain consistency
5. **`Toast.tsx` + `theme.ts` coupling**
- toast visuals encoded in TS style objects instead of ui primitives/token classes
6. **`App.css` + legacy selectors in `index.css`**
- `App.css` appears unused yet present
- `.card` / `.shadow-card` coexist with `ui-card`, increasing ambiguity
---
## 5) Highest-risk problem areas
- **Token drift risk**: same values duplicated in `tokens.css` and `theme.ts`.
- **App-shell inconsistency risk**: `App.tsx` not on design-system primitives.
- **Page-level divergence risk**: CookMode/Import URL/NotFound/MissionControl use different style dialect than core recipe pages.
- **Maintainability risk**: inline style overrides (`borderRadius`, `boxShadow`) bypass primitive contract and encourage per-component customization.
- **Regression risk**: no guardrails prevent new raw palette utilities in future UI work.
---
## 6) First conversion targets (for stabilization wave)
1. `src/App.tsx` (global shell contract)
2. `src/components/MissionControlPanel.tsx` (small, easy, high-visibility drift)
3. `src/pages/CookModePage.tsx` (largest isolated drift surface)
4. `src/pages/ImportUrlPage.tsx` (same drift pattern as CookMode)
5. `src/components/Toast.tsx` (inline-style dependency cleanup)
---
## 7) Acceptance criteria for stabilization work (T02+)
A stabilization pass is accepted when all are true:
1. **Single token authority**
- CSS vars in `tokens.css` are canonical.
- `theme.ts` no longer independently defines conflicting visual token values.
2. **Primitive contract adoption**
- app shell + major pages use shared `ui-*` primitives (or wrapper components built from them).
- new features avoid ad-hoc color utilities outside tokenized scheme.
3. **Inline-style reduction**
- no routine radius/shadow/color inline overrides where equivalent primitive/token class exists.
4. **Legacy debt disposition**
- `App.css` removed or explicitly retained with documented rationale.
- overlapping legacy helpers (`.card`, `.shadow-card`) either standardized or deprecated.
5. **Governance check**
- lightweight styling checklist/doc exists and is referenced in workflow.
---
## 8) Suggested next task after T01
**T02 — Canonical Token Source Lock**
- lock CSS tokens as source of truth
- redefine `theme.ts` as typed accessor only (or trim to non-visual helpers)
- document token contract before broad conversion starts

View File

@ -1,132 +0,0 @@
# T07 — Styling Stabilization QA Pass
Date: 2026-03-28
Task: **T07 — Stabilization QA Pass**
Scope: QA review of T02T06 outcomes in `recipe-manager/frontend`
## What was reviewed
Read docs:
- `.harness/styling-stabilization-execution-board.md`
- `.harness/docs/styling-inventory.md`
- `.harness/docs/styling-token-contract.md`
- `.harness/docs/ui-primitive-contract.md`
- `.harness/docs/styling-governance.md`
- `.harness/docs/t05-legacy-style-debt-cleanup.md`
- `docs/visual-audit/after/homepage-qa-note-t04.md`
Inspected implementation surfaces:
- `src/styles/tokens.css`
- `src/theme.ts`
- `src/index.css`
- `src/components/ui/primitives.tsx`
- `src/App.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/pages/RecipeListPage.tsx`
- `src/pages/CookModePage.tsx`
- `src/pages/ImportUrlPage.tsx`
- `src/components/RecipeCard.tsx`
- `src/components/Toast.tsx`
- `src/components/ErrorBoundary.tsx`
- `src/pages/NotFoundPage.tsx`
- `scripts/style-guardrails.mjs`
- `frontend/package.json`
Validation commands:
- `npm run style:guardrails` ✅ pass
- `npm run build` ✅ pass
- `npm run lint` ❌ fails (pre-existing non-styling issues; see findings)
- grep scan for raw palette utilities in app/page/component surfaces
---
## QA findings by severity
### High
1. **Known drift still present in unguarded surfaces (`ErrorBoundary`, `NotFoundPage`)**
- `src/components/ErrorBoundary.tsx` still uses raw Tailwind palette utilities (`bg-gray-*`, `text-gray-*`, `bg-blue-*`, `text-red-*`).
- `src/pages/NotFoundPage.tsx` still uses raw palette (`text-gray-*`, `bg-blue-*`).
- Risk: these are user-visible shell/error states and create immediate visual inconsistency with tokenized surfaces.
### Medium
2. **Guardrail scope is still too narrow for current stabilized claims**
- Current `style:guardrails` only enforces:
- `src/App.tsx`
- `src/components/MissionControlPanel.tsx`
- `src/components/ui/primitives.tsx`
- High-drift but now partially stabilized files (e.g., `RecipeListPage`, `ImportUrlPage`, `CookModePage`, `RecipeCard`) are not protected yet.
3. **Residual non-token palette usage remains in otherwise stabilized pages/components**
- `src/pages/RecipeListPage.tsx`: hero overlay gradient uses `from-slate-900...` / `via-slate-900...`.
- `src/components/RecipeCard.tsx`: card image overlay uses `from-slate-950...` / `via-slate-900...`.
- `src/components/Toast.tsx`: close button hover uses `hover:text-gray-200`.
- These are limited, but they are still direct palette leakage.
4. **Release hygiene risk: lint gate is red**
- `npm run lint` fails due to existing React/TS lint errors in `App.tsx`, `CookModePage.tsx`, `RecipeDetailPage.tsx`, `api.ts`, `types/api-aux.ts`.
- Not strictly a styling blocker, but it weakens governance confidence and CI-readiness.
### Low
5. **`theme.ts` still contains non-token hex values in `recipeAccentPalette`**
- Most token drift was eliminated correctly, but this transitional array still includes raw hex values.
- This is documented as an exception in T02; low-risk but still outside ideal contract purity.
---
## What is now considered stabilized
- **Token source authority:** `tokens.css` is effectively canonical; `theme.ts` now primarily references CSS vars instead of duplicating hardcoded token values.
- **Primitive layer exists and is adopted:** `UiPage`, `UiSection`, `UiCard`, `UiButton`, `UiChip`, `UiBadge`, `cn()` are implemented and used in key shell/surface paths.
- **App shell conversion landed:** `App.tsx` now follows tokenized/primitive-aligned styling patterns.
- **Mission Control conversion landed:** `MissionControlPanel.tsx` moved onto tokenized primitive usage.
- **Legacy cleanup landed:** `src/App.css` removed; legacy `.card` alias removed from `index.css`.
- **Governance baseline landed:** written governance + runnable guardrail command in package scripts.
---
## What still needs work
1. **Close remaining obvious drift surfaces**
- Convert `ErrorBoundary` and `NotFoundPage` to tokenized/primitive patterns.
2. **Finish palette cleanup on media overlays/edge controls**
- Replace remaining `slate-*`/`gray-*` utility references in `RecipeListPage`, `RecipeCard`, and `Toast` with semantic tokens.
3. **Expand guardrail coverage in phases**
- Add stabilized pages/components to `scripts/style-guardrails.mjs` scope incrementally (start with `NotFoundPage`, `ErrorBoundary`, `RecipeListPage`, `RecipeCard`, `Toast`).
4. **Address lint debt to improve release confidence**
- Resolve outstanding lint errors (especially `react-hooks/set-state-in-effect` and explicit `any` violations) so governance checks can become stronger CI gates.
5. **Optional contract hardening**
- Replace non-token hexes in `recipeAccentPalette` with token references.
---
## Recommended next execution-board task
**T08 (new): Guardrail Scope Expansion + Residual Drift Cleanup**
Proposed scope:
- Convert `ErrorBoundary` + `NotFoundPage` to tokenized primitives.
- Remove remaining raw palette classes in `RecipeListPage`, `RecipeCard`, `Toast`.
- Expand `style:guardrails` file scope to include these surfaces.
- Keep changes low-risk and styling-only.
If no new task id is allowed, treat this as **T07 follow-on patch set** before final signoff.
---
## Release gate status (from execution board)
- [x] One canonical token source with no major CSS/TS drift
- [x] Base primitives centralized and used across high-traffic surfaces
- [~] No new ad-hoc color classes outside tokenized set (mostly true, residual exceptions remain)
- [x] Legacy scaffold artifacts removed/documented
- [x] Governance doc + guardrail command exist
Overall: **Near-stable, not fully closed** due to residual palette drift and narrow guardrail scope.
## Ready for review
**Yes (with follow-on cleanup required before final “fully stabilized” signoff).**

View File

@ -1,50 +0,0 @@
# Styling Token Contract (T02)
Date: 2026-03-27
Scope: `recipe-manager/frontend`
## Canonical token source
**Single source of truth:**
- `src/styles/tokens.css`
All design-token values (color, typography scales, spacing, radius, elevation/focus) must be authored in `tokens.css` first.
## Allowed token consumption paths
1. **Preferred in UI markup:** tokenized classes and `ui-*` primitives from `src/index.css`
- Examples: `ui-card`, `ui-btn`, `text-[var(--text)]`, `border-[var(--border)]`
2. **Tailwind theme keys** mapped to CSS vars in `tailwind.config.js`
- Examples: `bg-surface`, `text-primary`, `shadow-card`
3. **TS access layer:** `src/theme.ts`
- Must expose `var(--...)` references only.
- Must not hardcode competing token values.
## Explicit non-contract patterns
- Do **not** introduce new hardcoded design-token values in `theme.ts`.
- Do **not** define duplicate token constants in TS that can drift from `tokens.css`.
- Avoid ad-hoc palette classes (`bg-blue-*`, `text-slate-*`, etc.) in shared shell/feature UI when tokenized equivalents exist.
## `theme.ts` role after T02
`theme.ts` is now a **typed token accessor/compatibility layer**, not an independent token definition file.
- ✅ Allowed: `colors.primary = 'var(--color-primary)'`
- ❌ Not allowed: `colors.primary = '#ea580c'`
If a token does not exist yet:
1. Add it to `tokens.css`
2. (Optional) Map it in `tailwind.config.js` if utility-class access is needed
3. Expose accessor in `theme.ts` only as `var(--token-name)`
## Practical authoring guide (for future tasks)
- Use `ui-*` classes first for common controls/layout shells.
- Use tokenized Tailwind utilities or `var(--...)` references for one-off styling.
- Keep inline `style={{ ... }}` for runtime/dynamic values only (e.g., tag color from DB), not for static design tokens.
## Current known exceptions (post-T02)
- Some components/pages still import `radius`/`colors` from `theme.ts` for inline styling; values are now token references, so no hardcoded drift remains.
- `recipeAccentPalette` still includes a few non-tokenized accent hexes pending a future semantic palette pass.

View File

@ -1,40 +0,0 @@
# T05 — Legacy Style Debt Cleanup
Date: 2026-03-27
Project: `recipe-manager/frontend`
## Scope
Conservative cleanup of clearly obsolete style artifacts after token/primitive contract stabilization.
## Changes made
### 1) Removed unused legacy scaffold stylesheet
- Deleted: `frontend/src/App.css`
- Rationale: file contained Vite starter/demo selectors (`.hero`, `.counter`, `#next-steps`, etc.) and is not imported by `main.tsx` or `App.tsx`.
- Risk: low (no import path, no app references).
### 2) Removed dead legacy helper selector from global stylesheet
- Updated: `frontend/src/index.css`
- Removed legacy `.card` selector alias block and kept `.shadow-card` (still actively used).
- Before: `.card, .shadow-card { ... }` plus `.shadow-card { ... }`
- After: `.shadow-card { ... }` only.
- Rationale: avoid duplicate/ambiguous card styling pathways now that `ui-card` is the primitive contract.
## Validation
- Verified no `App.css` imports in current frontend source.
- Verified no `className="card"` usage in current frontend source.
- Build check: `npm run build` passes.
## Deferred legacy debt (intentionally not removed)
1. **`.shadow-card` utility** in `index.css` — still used across pages/components (`RecipeForm`, `RecipeListPage`, `RecipeDetailPage`), so not safe to remove in T05.
2. **`.animate-slide-in` + `@keyframes slide-in`** — used by `components/Toast.tsx`; retained.
3. **Residual inline style usage from `theme.ts`** (e.g., `Toast`, `RecipeCard`) — not removed here to avoid broader UI behavior changes; belongs to follow-up conversion task.
4. **Responsive global utility overrides in `index.css` (`.max-w-*`, `.p-*`)** — potentially legacy/scaffold-ish but high blast-radius; defer pending design QA.
## Recommendation (next task)
Proceed with **T06 — Guardrails + Lightweight Governance**:
- codify that new UI must use `ui-*`/primitives and tokenized vars,
- add a checklist to prevent re-introducing dead aliases and raw palette drift.
## Ready for review
**Yes**

View File

@ -1,91 +0,0 @@
# T03 — UI Primitive Contract (Recipe Manager UI Kit)
Date: 2026-03-27
Scope: `recipe-manager/frontend`
## Goal
Stabilize core UI primitives so pages stop re-implementing button/card/chip/layout class strings.
This is a **low-risk contract layer**, not a redesign system.
---
## Contract surface (v1)
Implemented in:
- `frontend/src/components/ui/cn.ts`
- `frontend/src/components/ui/primitives.tsx`
### `cn(...values)`
- Lightweight class composer used by primitives and page-level variant helpers.
- Avoids repetitive template-string class logic.
### `UiPage`
- Canonical page shell wrapper (`ui-page` + optional additions).
- Use for top-level layout regions (header container, footer container, page root if needed).
### `UiSection`
- Canonical section shell (`ui-section`) with controlled padding options:
- `padding="md"` (default)
- `padding="lg"`
- `padding="none"`
- Use for grouped content blocks and major page sections.
### `UiCard`
- Canonical card surface (`ui-card`) with optional tone:
- `tone="default"`
- `tone="muted"`
- Use for repeatable content cards and skeleton containers.
### `UiButton`
- Canonical button primitive (`ui-btn` + variant mapping):
- `variant="primary"`
- `variant="secondary"` (default)
- Use for interactive `<button>` actions.
### `UiLinkButton`
- Anchor-style button for actual links (kept for future use where semantic `<a>` is needed).
### `UiChip` / `UiBadge`
- Canonical chip and badge wrappers around `ui-chip` / `ui-badge`.
- Use for compact status pills/filter tokens.
---
## Representative adoption in T03
### 1) App shell (`src/App.tsx`)
- Header and footer wrappers now use `UiPage`.
- Navigation active/inactive styles now use a `cn(...)` helper contract rather than bespoke duplicated class strings.
- Shell color treatment shifted to tokenized variables (`var(--...)`) instead of slate/blue freelancing.
### 2) Mission Control (`src/components/MissionControlPanel.tsx`)
- Converted from gray utility block to tokenized contract primitives:
- `UiSection` for panel shell
- `UiChip` for status pills
- Removes bespoke gray class styling drift from this shared panel.
### 3) Recipe list page (`src/pages/RecipeListPage.tsx`)
- Core repeated shells now use primitives:
- hero wrapper and filter blocks via `UiSection`
- feature cards and loading skeleton via `UiCard`
- key actions (`Search`, `Load More`, `Clear all filters`) via `UiButton`
- This proves the contract on a high-traffic page without broad redesign.
---
## Usage guidance for follow-on tasks
1. Prefer `UiSection`/`UiCard`/`UiButton` before writing new utility-heavy class blocks.
2. Use tokenized vars (`var(--...)`) for one-off values; avoid raw palette classes in shared surfaces.
3. Keep inline `style={{ ... }}` for runtime values only (e.g., tag color from data), not static radius/shadow/colors.
4. When classes need conditional composition, use `cn(...)` in one helper instead of many inlined strings.
---
## Intentionally deferred (not in T03)
- Broad conversion of `CookModePage`, `ImportUrlPage`, and full `RecipeDetailPage`.
- Full button/link semantic normalization (e.g., dedicated router-aware LinkButton primitive).
- Removal of all legacy one-off class usage in older components.
These are better handled in T04/T05 to keep T03 low-risk.

View File

@ -1,234 +0,0 @@
# Recipe Manager — Image Import & Backend Hardening Execution Board
Created: 2026-03-27
Owner: Main Orchestrator
Status: READY
## Objective
Ship a reliable URL import pipeline that returns high-quality `image_url` values and harden backend safety/perf around migrations, search, and TypeScript correctness.
## Current Repo Reality (Baseline)
- `POST /api/import/url` is currently a stub returning `{ draft_recipe: null }` (`src/backend/routes/import.ts`).
- URL fetch + JSON-LD extraction foundation exists (`src/backend/services/UrlImportService.ts`) but is not wired to route.
- Schema.org/heuristic parser services are minimal and weakly typed (`any` heavy).
- `image_url` exists in schema/repository/routes/seed (`schema.sql`, `RecipeRepository`, `recipes.ts`), but no explicit image validation/fallback policy.
- Runtime migration helper exists (`applyRuntimeMigrations`) with no direct migration tests.
- Search currently uses `%LIKE%` joins across recipes/ingredients/tags; indexes are limited and query plan unverified.
- TS strict mode is on, but `any` and contract drift remain in backend/frontend types.
## Release Gate (Done Definition)
- [ ] `/api/import/url` performs real fetch/parse flow and returns non-empty `draft_recipe` for supported pages.
- [ ] Image extraction ranking + URL validation policy is implemented and tested.
- [ ] Runtime migration behavior is covered by tests for pre/post `image_url` schemas.
- [ ] Search performance/query behavior validated with index and query-shape improvements.
- [ ] TS hygiene pass removes prioritized unsafe `any` in import/search pathways.
---
## Task Backlog
### T01 — Import Route Orchestration (Wire Real Pipeline)
Priority: P0
Owner: agent-import-core
Dependencies: none
Deliverables:
- Replace stub in `src/backend/routes/import.ts` with real pipeline:
- validate URL input via zod
- call `UrlImportService.fetchFromUrl`
- parse JSON-LD blocks and select recipe candidate
- fallback to heuristic parser when schema parse fails
- return structured `UrlImportResult` payload expected by frontend (`draft_recipe`, `source_url`, parse metadata)
- Error mapping from `UrlImportError` to stable HTTP/API errors for UI (`timeout`, `network`, `unsupported content`, parse failure).
Acceptance:
- [ ] Import route no longer returns hardcoded null draft.
- [ ] `src/backend/tests/import.test.ts` includes success + invalid URL + timeout/network/content-type failures.
- [ ] Frontend import page can reach “review” stage from at least one fixture HTML sample.
---
### T02 — Schema.org Image Extraction Quality Pass
Priority: P0
Owner: agent-import-parser
Dependencies: T01
Deliverables:
- Refactor `SchemaOrgRecipeParserService` to support common image variants:
- string URL
- array of URLs
- array/object entries with `url`, `contentUrl`, `thumbnailUrl`
- `@graph` recipe object selection when JSON-LD block is graph-shaped
- Add image candidate ranking (prefer HTTPS, largest/default image over tiny thumbnails when width/height available).
- Return normalized draft with stable `image_url` candidate.
Acceptance:
- [ ] Parser tests added with fixtures for at least 5 JSON-LD image shapes.
- [ ] For each fixture, expected top `image_url` is asserted.
- [ ] No parser path throws on malformed/partial image fields; degrades gracefully.
---
### T03 — Image URL Validation & Fallback Policy
Priority: P0
Owner: agent-import-hardening
Dependencies: T02
Deliverables:
- Implement central image URL sanitizer/validator utility (new service module):
- allow `http/https` only (or strict `https` with optional downgrade rule)
- reject `data:`, `javascript:`, blob/non-web URLs
- trim + normalize empty values to `null`
- optional host allow/deny controls (documented default policy)
- Integrate validator in:
- import parsing output
- recipe create/update flows (`routes/recipes.ts` + repository normalization layer)
- Define fallback order for import draft image:
1) validated schema.org image
2) validated heuristic image
3) null (no placeholder persisted)
Acceptance:
- [ ] Unit tests cover allow/reject matrix for image URLs.
- [ ] Creating/updating recipe with invalid image URL is predictably rejected or nulled per policy.
- [ ] Policy documented in `docs/api.md` (import + recipe payload behavior).
---
### T04 — Migration Coverage for `recipes.image_url`
Priority: P1
Owner: agent-db-safety
Dependencies: none (can run parallel to T02/T03)
Deliverables:
- Add migration tests for `applyRuntimeMigrations`:
- DB with no `recipes` table (no-op)
- DB with `recipes` but no `image_url` (column added)
- DB already containing `image_url` (idempotent)
- Add integration check around `migrate.ts`/`database.ts` startup path to ensure migration executes before repository usage.
Acceptance:
- [ ] Dedicated test file exists under `src/backend/tests` (or `src/backend/db/__tests__`).
- [ ] Tests assert column presence via `PRAGMA table_info(recipes)`.
- [ ] Re-running migration tests proves idempotence.
---
### T05 — Import Contract Alignment (Frontend/Backend Types)
Priority: P1
Owner: agent-contracts
Dependencies: T01
Deliverables:
- Define shared import response contract (backend + frontend) for:
- `draft_recipe`
- `source_url`
- parse provenance (`schema_org_used`, `heuristic_used`, warning list)
- Align frontend `UrlImportResult` and backend route payload to avoid required-field mismatch.
- Ensure `image_url` is represented consistently in frontend recipe interfaces where used by UI components.
Acceptance:
- [ ] TypeScript build/test passes without contract casts for import payload.
- [ ] Import UI displays parse metadata without runtime undefined errors.
- [ ] `frontend/src/types/*` and backend response shape are synchronized.
---
### T06 — Search Query + Index Optimization
Priority: P1
Owner: agent-data-perf
Dependencies: none
Deliverables:
- Review/adjust recipe search query shape in `RecipeRepository.findAll/count` to reduce costly wide joins where possible.
- Add/validate indexes to support current search/filter path (evaluate at minimum):
- `recipes(created_at)` for default order
- `recipe_tags(recipe_id)` complementing existing `recipe_tags(tag_id)`
- optional composite/index refinements based on `EXPLAIN QUERY PLAN`
- Capture query plan before/after on seeded dataset.
Acceptance:
- [ ] Query plan evidence saved under `docs/perf/search-query-plan.md`.
- [ ] Search + tag filter behavior unchanged functionally (existing tests still pass).
- [ ] Measured improvement or justified no-op documented.
---
### T07 — TS Hygiene Pass (Import/Data Path First)
Priority: P2
Owner: agent-ts-hygiene
Dependencies: T01, T05
Deliverables:
- Remove high-risk `any` from import/parser/repository hot paths:
- `SchemaOrgRecipeParserService`
- `RecipeRepository` filter destructuring
- import-related tests/types
- Introduce narrow helper types/guards for JSON-LD blocks instead of raw `any`.
- Keep broader orchestrator generics untouched unless directly impacted.
Acceptance:
- [ ] No `any` remains in import route + parser services.
- [ ] Repository filter casts no longer use `filters as any`.
- [ ] `npm run build` passes with strict mode unchanged.
---
### T08 — Docs + Runbook Truth Sync
Priority: P2
Owner: agent-docs-sync
Dependencies: T01, T03, T05
Deliverables:
- Update stale API/import docs to match actual payloads and behavior (`docs/api.md`, relevant README sections).
- Document known import limits and expected failure messages.
- Add “how to add parser fixture” note for future maintainers.
Acceptance:
- [ ] Docs describe real import endpoint behavior (not aspirational).
- [ ] Example request/response includes `image_url` handling rules.
- [ ] No references to removed/incorrect field names in import examples.
---
## Wave Plan (Execution Order)
### Wave 1 — Restore Functional Import Core
- T01 (route orchestration)
### Wave 2 — Image Quality + Safety
- T02 (schema image extraction)
- T03 (validation/fallback policy)
- T05 (contract alignment)
### Wave 3 — Backend Hardening in Parallel
- T04 (migration coverage)
- T06 (search/index optimization)
### Wave 4 — Cleanup + Stability
- T07 (TS hygiene)
- T08 (docs truth sync)
## Dependency Notes
- T03 depends on T02 because sanitizer policy should apply to ranked image candidates.
- T05 depends on T01 because real response shape must exist before contract lock.
- T07 should start after T01/T05 to avoid churn from contract refactors.
## Recommended Starting Order (Concrete)
1. **T01** — unblock real import behavior and expose true integration issues.
2. T02 — improve image extraction quality where import currently underperforms.
3. T03 — enforce URL safety/normalization policy.
4. T05 — lock backend/frontend import contract and image fields.
5. Parallel: T04 + T06.
6. T07 then T08.
## First Task to Launch
**Launch T01 — Import Route Orchestration (Wire Real Pipeline).**
Reason: it converts a stubbed endpoint into executable behavior and creates the integration baseline needed by every downstream image-quality and hardening task.
## Reporting Protocol (for each task)
1) task id
2) files changed
3) tests added/updated + command output
4) blockers/risks
5) ready-for-review flag

View File

@ -1,190 +0,0 @@
# Local File Import — Execution Board
**Created:** 2026-03-28
**Owner:** Cleo (orchestrator)
**Goal:** Implement functionality to import recipes from local .txt and .html files exported from CopyMeThat
**Status:** 🚧 In Progress
---
## Context
Paul has exported recipes from CopyMeThat in both .txt and .html formats. The Recipe Manager currently lacks functionality to import local files. This execution board tracks the implementation of this feature from file parsing through UI integration.
**Critical constraint:** Sub-agent spawning is currently blocked, so all work will be done in the main thread by Cleo.
---
## Task Breakdown
### Phase 1: Analysis & Design ✅
**Status:** Complete
**Tasks:**
- [x] T1.1: Analyze CopyMeThat export file formats (.txt and .html)
- [x] T1.2: Design import data pipeline (parse → validate → insert)
- [x] T1.3: Define backend API endpoint contract
- [x] T1.4: Define frontend UI flow (file selection → preview → confirm)
**Deliverables:**
- ✅ File format specification document (`.harness/local-file-import-phase1-analysis.md`)
- ✅ Import service architecture design
- ✅ API endpoint spec
- ✅ UI wireframe/flow diagram
---
### Phase 2: Backend Implementation 🚧
**Status:** In Progress (90% complete)
**Tasks:**
- [x] T2.0: Extend database schema with `made`, `rating`, `notes` fields
- [x] T2.1: Create file parser for .txt format (`CopyMeThatTxtParser.ts`)
- [x] T2.2: Create file parser for .html format (`CopyMeThatHtmlParser.ts`)
- [x] T2.3: Implement import service with duplicate detection (`CopyMeThatImportService.ts`)
- [x] T2.4: Create POST `/api/import/local` endpoint with multer file upload
- [x] T2.5: Register route in Express app
- [x] T2.6: Install multer dependency
- [ ] T2.7: Add unit tests for parsers
- [ ] T2.8: Add integration tests for import endpoint
**Deliverables:**
- ✅ `src/backend/services/CopyMeThatHtmlParser.ts`
- ✅ `src/backend/services/CopyMeThatTxtParser.ts`
- ✅ `src/backend/services/CopyMeThatImportService.ts`
- ✅ `src/backend/routes/importLocal.ts`
- ✅ Schema migration (`migrations/2026-03-28-add-user-metadata-fields.md`)
- ⏳ Test coverage (pending)
---
### Phase 3: Frontend Implementation
**Status:** Pending
**Tasks:**
- [ ] T3.1: Create file upload component with drag-and-drop
- [ ] T3.2: Build import preview screen (show parsed recipes before commit)
- [ ] T3.3: Add progress indicator for batch imports
- [ ] T3.4: Implement error handling & user feedback
- [ ] T3.5: Add import page to navigation
**Deliverables:**
- `src/frontend/components/ImportRecipes.tsx`
- `src/frontend/components/ImportPreview.tsx`
- `src/frontend/pages/ImportPage.tsx`
- Styling consistent with existing UI
---
### Phase 4: Testing & Polish
**Status:** Pending
**Tasks:**
- [ ] T4.1: End-to-end test: import single .txt file
- [ ] T4.2: End-to-end test: import single .html file
- [ ] T4.3: End-to-end test: batch import multiple files
- [ ] T4.4: Manual testing with Paul's actual CopyMeThat exports
- [ ] T4.5: Performance testing (100+ recipes)
- [ ] T4.6: Update user documentation
**Deliverables:**
- E2E test suite
- Performance benchmarks
- Updated `docs/user-guide.md` with import instructions
---
### Phase 5: Deployment
**Status:** Pending
**Tasks:**
- [ ] T5.1: Build and test Docker image
- [ ] T5.2: Deploy to paje.ca staging
- [ ] T5.3: User acceptance testing (Anne & Elizabeth)
- [ ] T5.4: Production deployment
- [ ] T5.5: Data migration (import Paul's CopyMeThat exports)
**Deliverables:**
- Deployed feature on recipes.paje.ca
- Migrated recipe data
- Release notes
---
## Dependencies
- CopyMeThat export sample files (need from Paul)
- Existing Recipe schema/validation (already implemented)
- Current backend API structure
- Frontend routing and navigation patterns
---
## Technical Decisions
### File Format Handling
- **Decision:** Support both .txt and .html, with .html as primary (more structured data)
- **Rationale:** .txt is human-readable backup, .html likely has better metadata
- **Risk:** Unknown format specifics until we see sample files
### Duplicate Detection
- **Decision:** Check for exact title + ingredients match
- **Rationale:** Simple, fast, low false-positive rate
- **Alternative:** Could add fuzzy matching later (v1.1)
### Batch Import Strategy
- **Decision:** Parse all files first, show preview, then bulk insert
- **Rationale:** Gives user control, prevents partial imports on error
- **Trade-off:** Higher memory usage for large batches (acceptable for <1000 recipes)
### Error Recovery
- **Decision:** Log failures, continue processing remaining files
- **Rationale:** One bad file shouldn't block entire import
- **UX:** Show summary with success/failure counts
---
## Risks & Mitigation
| Risk | Impact | Mitigation |
|------|--------|------------|
| Unknown file format structure | High | Request sample files from Paul immediately |
| Large file performance | Medium | Implement streaming parser if needed |
| Encoding issues (UTF-8, special chars) | Medium | Use robust parser library, test with international recipes |
| Duplicate recipe handling | Low | Clear UI messaging, offer merge/skip options |
---
## Acceptance Criteria
✅ **Must Have:**
1. User can upload .txt or .html files via UI
2. System correctly parses CopyMeThat export formats
3. User sees preview of recipes before import
4. Duplicate detection prevents accidental re-imports
5. Batch import works for 100+ files
6. Clear error messages for invalid files
7. Documentation updated with import instructions
🎯 **Nice to Have:**
- Drag-and-drop file upload
- Progress bar for large batches
- Import history log
- Undo/rollback option
---
## Progress Log
**2026-03-28 23:46 EDT — Kickoff**
- Execution board created
- Awaiting CopyMeThat export sample files from Paul
- Next step: Analyze file formats and begin Phase 1
---
## Notes
- Paul has files ready in Downloads folder (.txt and .html)
- Sub-agent spawning currently broken, all work in main thread
- Target completion: 24-48 hours (depending on file format complexity)
- This feature unblocks Anne & Elizabeth from migrating to Recipe Manager
---
_Last updated: 2026-03-28 23:50 EDT by Cleo_

View File

@ -1,243 +0,0 @@
# Phase 1: CopyMeThat Format Analysis
**Completed:** 2026-03-28 23:52 EDT
**Analyzed by:** Cleo
---
## File Structure
### TXT Export
- **Location:** `data/exports/Copy_Me_That_TXT_20260328_58775_z1p5lpjsgz/`
- **Format:** One `.txt` file per recipe
- **Naming:** Snake-case recipe names (e.g., `apple_crowned_coffee_cake.txt`)
- **Estimated count:** ~50-100+ recipes
### HTML Export
- **Location:** `data/exports/Copy_Me_That_HTML_20260328_58775_z1p5lpjsgz/`
- **Format:** Single `recipes.html` file containing ALL recipes
- **Images:** `/images/` subfolder with recipe photos
- **Structure:** Semantic HTML with consistent IDs
---
## TXT Format Specification
### Structure
```
[Recipe Title]
Adapted from [URL]
tags: [Tag1], [Tag2], [Tag3]
[Optional: "I made this."]
Servings: [serving info]
INGREDIENTS
[ingredient 1]
[ingredient 2]
...
STEPS
1) [step 1]
2) [step 2]
...
NOTES
[optional notes]
```
### Example
```
Apple-Crowned Coffee Cake
Adapted from http://www.kraftcanada.com/recipes/apple-crowned-coffee-cake-191423
tags: Cake, Dessert
I made this.
Servings: 16 servings, 1 piece (76 g) each
INGREDIENTS
2 cups flour
2 Tbsp. granulated sugar
...
STEPS
1) Heat oven to 375°F.
2) Combine flour...
NOTES
If the glaze is too thick...
```
### Key Fields
- **Title:** First line
- **Source URL:** "Adapted from [URL]"
- **Tags:** Comma-separated after "tags:"
- **Made flag:** Presence of "I made this."
- **Servings:** After "Servings:"
- **Ingredients:** Plain list between "INGREDIENTS" and "STEPS"
- **Instructions:** Numbered list after "STEPS"
- **Notes:** Optional, after "NOTES"
---
## HTML Format Specification
### Structure
Single HTML file with repeated `.recipe` div blocks:
```html
<div class="recipe">
<div id="name">Recipe Title</div>
<div id="link">
Adapted from <a id="original_link" href="...">URL</a>
</div>
<img class="recipeImage" src="images/filename.jpg"/>
<div id="categories">
<span class="recipeCategory">Tag1</span>
<span class="recipeCategory">Tag2</span>
</div>
<div id="description">Description text</div>
<div id="extra_info">
<span id="made_this">I made this.</span>
<span id="rating">Rated <span id="ratingValue">3</span>/5</span>
</div>
<div id="servings">
Servings: <a id="recipeYield">8 servings...</a>
</div>
<ul id="recipeIngredients">
<li class="recipeIngredient">ingredient 1</li>
...
</ul>
<ol id="recipeInstructions">
<li class="instruction" value="1">step 1</li>
...
</ol>
<div id="recipeNotes">
<div class="recipeNote">note text</div>
</div>
</div>
```
### Key Selectors
- `.recipe` — Recipe container
- `#name` — Title
- `#original_link` — Source URL
- `.recipeImage` — Image path
- `.recipeCategory` — Tags
- `#description` — Description
- `#made_this` — Made flag
- `#ratingValue` — Rating (1-5)
- `#recipeYield` — Servings
- `.recipeIngredient` — Ingredients (list items)
- `.instruction` — Steps (ordered list items)
- `.recipeNote` — Notes
---
## Implementation Strategy
### Recommended Approach: HTML Parser Primary
**Rationale:**
- HTML has MORE data (images, ratings, descriptions)
- Single file = easier batch import
- Well-structured semantic markup
- Images already linked
**Fallback:** TXT parser for edge cases
### Parser Architecture
```
ImportService
├── CopyMeThatHtmlParser
│ ├── parseRecipes(html: string): Recipe[]
│ ├── extractRecipeBlocks(html: string): HTMLElement[]
│ └── parseRecipeBlock(block: HTMLElement): Recipe
└── CopyMeThatTxtParser (optional fallback)
└── parseTxtFile(content: string): Recipe
```
### API Endpoint Design
```
POST /api/recipes/import/copyme that
Content-Type: multipart/form-data
Request:
- file: recipes.html OR multiple .txt files
- options: { skipDuplicates: boolean, importImages: boolean }
Response:
{
success: true,
data: {
imported: 45,
skipped: 3,
failed: 2,
recipes: [...] // preview
}
}
```
---
## Data Mapping
| CopyMeThat Field | Recipe Schema Field | Notes |
|------------------|---------------------|-------|
| `#name` | `title` | Direct mapping |
| `#original_link` | `source_url` | Direct mapping |
| `#description` | `description` | Direct mapping |
| `.recipeCategory` | `tags` | Parse into tag array |
| `#recipeYield` | `servings` | Extract number if possible |
| `.recipeIngredient` | `ingredients[].item` | Plain text list |
| `.instruction` | `steps[].instruction` | Numbered list |
| `.recipeNote` | Notes field? | May need schema extension |
| `.recipeImage` | `image_url` | Copy to app storage |
| `#made_this` | Custom field? | Boolean flag |
| `#ratingValue` | Custom field? | 1-5 rating |
### Schema Extensions Needed
- `made: boolean` — User has cooked this
- `rating: number` — 1-5 stars
- `notes: string` — General notes field
---
## Edge Cases to Handle
1. **Duplicate detection** — Match on title + source_url
2. **Missing fields** — Title/ingredients/steps are required
3. **Image handling** — Copy images or store paths?
4. **Encoding** — UTF-8 special characters
5. **HTML entities**`&amp;`, `&quot;`, etc.
6. **Large batches** — Memory limits for 100+ recipes
7. **Malformed HTML** — Graceful degradation
---
## Next Steps (Phase 2)
1. Extend Recipe schema with `made`, `rating`, `notes` fields
2. Implement `CopyMeThatHtmlParser` service
3. Create `POST /api/recipes/import/file` endpoint
4. Add multipart file upload handler
5. Unit tests for parser
6. Integration tests for endpoint
---
**Status:** ✅ Analysis complete, ready for implementation

View File

@ -1,225 +0,0 @@
# Recipe Manager — Styling Stabilization Execution Board
Created: 2026-03-27
Owner: Main Orchestrator
Status: READY
## Objective
Stabilize frontend styling so recipe-manager has a single, predictable styling system (tokens + reusable UI primitives + clear usage rules) before any further visual redesign work.
## Current Repo Reality (Baseline)
### recipe-manager (frontend)
- Stack is **React + Vite + Tailwind v4** (`frontend/package.json`).
- There is an existing token layer in CSS variables (`src/styles/tokens.css`) and Tailwind mapping (`tailwind.config.js`).
- A reusable utility-class layer exists in `src/index.css` (`.ui-btn`, `.ui-card`, `.ui-input`, `.ui-chip`, `.ui-page`, `.ui-section`).
- A parallel TS token file exists (`src/theme.ts`) with overlapping values (colors/radius/shadows/typography) duplicated from CSS tokens.
- Styling is currently mixed:
- some components use the `ui-*` classes and CSS variable tokens,
- some use Tailwind utility colors directly (`bg-blue-*`, `text-slate-*`, `border-gray-*` etc.),
- some use inline style objects from `theme.ts` (`style={{ boxShadow: shadows.card, borderRadius: radius.lg }}`),
- `src/App.css` still contains legacy scaffold/demo styles not aligned with the current design system,
- `MissionControlPanel.tsx` uses older gray utility styling and does not follow `ui-*` primitives.
- Result: design drift risk is high because there is no enforced “one true path” for styling.
### Fintrove (packages/fintrove-app)
- Uses **Next.js + Tailwind v3 + shadcn/ui pattern**.
- Evidence:
- `components.json` configured for shadcn (`style: "new-york"`, aliases, cssVariables true).
- `src/components/ui/*` contains shadcn-style primitives (Button, Card, Input, etc.).
- `@radix-ui/*`, `class-variance-authority`, `clsx`, `tailwind-merge` are installed and actively used.
- `src/lib/utils.ts` exposes `cn()` pattern (`twMerge(clsx(...))`).
- App pages/modules heavily import `@/components/ui/*` components.
- Practical note: Fintrove still has some custom CSS for layout edge cases (sidebar), but **core UI is standardized through primitives**.
## Release Gate (Done Definition)
- [ ] recipe-manager has one canonical token source (CSS variables) with no drift between CSS and TS token constants.
- [ ] Base UI primitives are centralized and used across pages/components for buttons, cards, inputs, chips/badges, section shells.
- [ ] No new feature UI uses raw ad-hoc color classes outside tokenized/theme-approved set.
- [ ] Legacy/unused styling artifacts are removed or documented as intentionally retained.
- [ ] A lightweight styling governance doc/checklist exists so future changes remain consistent.
---
## Recommendation Decision
### Recommended direction
**Keep current Tailwind + tokenized CSS variables + shared UI components (Fintrove-inspired discipline), optionally adopting selective shadcn patterns later.**
### Why (repo-specific)
1. recipe-manager already has a strong token foundation (`tokens.css` + `ui-*` classes) and visual language in flight.
2. A full migration to Mantine/Chakra now would add heavy churn and force broad rewrites for limited short-term value.
3. Full shadcn adoption now is possible, but it also introduces migration overhead (Radix + CVA component rewiring) while the current app can be stabilized faster by consolidating what already exists.
4. Best immediate leverage from Fintrove is **process and component architecture**, not a wholesale framework switch:
- centralized primitives,
- strict use of shared components,
- `cn()` utility + variant patterns where helpful.
### Option stance
- **Keep Tailwind + shared UI components:** ✅ **Recommended now**
- **Adopt shadcn/ui immediately:** ⚠️ optional phase-2 enhancement, not first move
- **Adopt Mantine/Chakra:** ❌ not recommended for current phase
- **Borrow Fintrove patterns:** ✅ yes — especially primitives + usage discipline
---
## Task Backlog
### T01 — Styling Inventory + Drift Map
Priority: P0
Owner: agent-style-audit
Dependencies: none
Deliverables:
- Component/page inventory tagged by styling mode:
- `ui-*` primitives,
- raw Tailwind utilities,
- inline `theme.ts` styles,
- legacy CSS (`App.css`) usage.
- Drift report listing top inconsistency hotspots (starting with `App.tsx`, `MissionControlPanel.tsx`, major pages).
Acceptance:
- [ ] Written inventory committed under `.harness/docs/styling-inventory.md`.
- [ ] Every top-level page and shared component classified.
- [ ] Explicit “first conversion targets” list produced.
---
### T02 — Canonical Token Source Lock
Priority: P0
Owner: agent-design-system
Dependencies: T01
Deliverables:
- Decide and document canonical token authority (CSS variable tokens in `tokens.css`).
- Align/trim `theme.ts` so it becomes a typed accessor/mirror (or remove overlapping hardcoded values where possible).
- Add a short token usage guide (when to use class vs CSS var vs helper constant).
Acceptance:
- [ ] No conflicting duplicate token values between CSS and TS token surfaces.
- [ ] Token mapping doc exists (`.harness/docs/styling-token-contract.md`).
- [ ] New token additions have one defined source-of-truth path.
---
### T03 — UI Primitive Contract (Recipe Manager UI Kit)
Priority: P0
Owner: agent-ui-foundation
Dependencies: T02
Deliverables:
- Formalize minimal primitives (Button/Card/Input/Badge/Section/Page shell) using existing `ui-*` base.
- Add composable wrappers/components where needed to reduce repeated class strings.
- Introduce `cn()` utility (Fintrove pattern) if class composition complexity requires it.
Acceptance:
- [ ] Shared primitive surface exists and is documented.
- [ ] At least 3 high-traffic screens consume primitives instead of duplicated class blocks.
- [ ] No regression in behavior/accessibility for converted areas.
---
### T04 — High-Drift Screen Conversions
Priority: P1
Owner: agent-core-ui
Dependencies: T03
Deliverables:
- Convert highest inconsistency files first:
- `src/App.tsx` nav/header/footer,
- `src/components/MissionControlPanel.tsx`,
- one primary page (`RecipeListPage` or `RecipeDetailPage`) where mixed style patterns are most visible.
- Replace raw color utility classes with token-aware variants/primitives where practical.
Acceptance:
- [ ] Converted files show clear reduction in ad-hoc style usage.
- [ ] Visual parity maintained (or intentionally improved with notes).
- [ ] No TypeScript/runtime regressions.
---
### T05 — Legacy Style Debt Cleanup
Priority: P1
Owner: agent-style-cleanup
Dependencies: T04
Deliverables:
- Remove or quarantine stale scaffold styles in `src/App.css`.
- Remove dead class names/selectors no longer referenced.
- Add comments for any intentionally retained exceptions.
Acceptance:
- [ ] `App.css` no longer contains unrelated starter/template styling.
- [ ] Dead style selectors removed.
- [ ] Build output CSS size/change reviewed and documented.
---
### T06 — Guardrails + Lightweight Governance
Priority: P1
Owner: agent-qa-polish
Dependencies: T04, T05
Deliverables:
- Add a concise styling contribution checklist (`.harness/docs/styling-governance.md`).
- Add lightweight guardrail command in frontend (`npm run style:guardrails`) for scoped app-shell/primitive checks.
- PR checklist items for token/primitive usage and accessibility checks.
Acceptance:
- [ ] Governance doc exists and is referenced by board/task workflow.
- [ ] At least one automated or semi-automated guardrail in place.
- [ ] Team can explain “how to style new UI here” in <2 minutes.
---
### T07 — Stabilization QA Pass
Priority: P1
Owner: agent-qa-polish
Dependencies: T06
Deliverables:
- Before/after screenshots of converted surfaces.
- Keyboard focus + contrast spot-check on converted components.
- Regression notes and signoff summary.
Acceptance:
- [ ] QA notes committed under `.harness/docs/styling-stabilization-qa.md`.
- [ ] No critical visual regressions on mobile/desktop.
- [ ] Release gate checklist completed.
---
## Wave Plan (Execution Order)
### Wave 1 — Discovery + Rules
- T01 (inventory/drift map)
- T02 (token source lock)
### Wave 2 — Foundation
- T03 (primitive contract)
### Wave 3 — Conversion + Cleanup
- T04 (high-drift screens)
- T05 (legacy style debt)
### Wave 4 — Prevent Regressions + Signoff
- T06 (guardrails/governance)
- T07 (stabilization QA)
## Dependencies Summary
- T02 depends on T01 so token decisions are based on real usage.
- T03 depends on T02 to avoid rebuilding primitives on unstable tokens.
- T04/T05 follow T03 for consistent rollout.
- T06/T07 close the loop and keep drift from reappearing.
## First Task to Launch
**Launch T01 — Styling Inventory + Drift Map.**
Reason: this repo already has multiple parallel styling paths. Without an explicit inventory, refactor work will be noisy and may increase inconsistency. T01 gives the concrete map needed to sequence low-risk, high-impact stabilization.
## Reporting Protocol (for each task)
1) task id
2) files changed
3) before/after evidence (screenshots or diffs)
4) blockers/risks
5) ready-for-review flag

View File

@ -1,70 +0,0 @@
# Recipe Manager — Visual Refresh Task Plan (Agentic Harness)
## Goal
Transform the site from plain white UI into a polished, appetizing product experience with consistent styling, imagery, and modern visual hierarchy.
## Task 1 — Design Tokens + Global Theme Foundation
**Scope**
- Establish color palette, typography scale, spacing/radius/shadow tokens
- Add global background treatment (subtle gradient/texture)
- Normalize button/input/card styles
- Ensure contrast/accessibility baseline
**Success Criteria**
- Theme variables centrally defined and used globally
- App no longer appears “flat white”
- Build/tests pass
---
## Task 2 — Homepage + Recipe List Visual Upgrade
**Scope**
- Add hero section with food imagery
- Improve recipe cards with thumbnails/placeholders, depth, and metadata chips
- Better empty/loading states with visuals
- Keep layout responsive
**Success Criteria**
- Homepage/list feels branded and visual
- Cards look rich and scannable on desktop/mobile
- Build/tests pass
---
## Task 3 — Recipe Detail + Import Flow Polish
**Scope**
- Visual hierarchy for recipe detail sections (ingredients, steps, tags)
- Add image block/header treatment
- Improve import screens with progress visuals and status indicators
**Success Criteria**
- Detail/import pages match refreshed style
- Clear phase/status UX
- Build/tests pass
---
## Task 4 — Micro-interactions + Motion + Iconography
**Scope**
- Add subtle transitions, hover/focus states, and motion consistency
- Introduce icon accents where useful (not noisy)
- Polish nav/header interactions
**Success Criteria**
- UI feels alive but not distracting
- Keyboard focus remains strong
- Build/tests pass
---
## Task 5 — Asset Strategy + Final QA Sweep
**Scope**
- Add/organize image assets and fallback strategy
- Verify no broken images/layout regressions
- Run full verification (`npm test`, app build/start command if available)
- Update docs with visual system notes
**Success Criteria**
- End-to-end visual refresh stable
- No obvious regressions
- Final commit + summary with before/after notes

View File

@ -1,73 +0,0 @@
# Recipe Manager — Agentic Harness Task Plan (Workflow Stabilization)
## Goal
Implement and validate a reliable automated workflow run + reporting + failure alert loop, in small isolated chunks.
## Task 1 — Add scheduler script + npm script
**Scope**
- Create `scripts/schedule-workflow.ts` that:
- Runs workflow in resume mode
- Generates morning report afterward
- Exits non-zero on hard failure
- Add npm script in `package.json`:
- `workflow:schedule`: `ts-node scripts/schedule-workflow.ts`
**Constraints**
- Keep script focused (no external integrations yet)
- Reuse existing runner/report code
**Success criteria**
- `npm run workflow:schedule` executes without TypeScript/runtime errors
- Commit with conventional commit message
---
## Task 2 — Add failure-state detector utility
**Scope**
- Create `scripts/check-workflow-health.ts` that reads status file and exits:
- `0` for healthy (`running|completed|idle`)
- `1` for unhealthy (`failed|blocked`)
- Print concise machine-readable output for automations
**Success criteria**
- Script behavior validated by direct execution + simple tests (or assertions)
- Conventional commit
---
## Task 3 — Add tests for schedule + health check scripts
**Scope**
- Add tests under `scripts/__tests__/`:
- scheduler happy path
- scheduler failure propagation
- health check status mapping
**Success criteria**
- `npm test` passes
- Conventional commit
---
## Task 4 — Wire lightweight local automation docs
**Scope**
- Update README/RUNBOOK with exact commands for periodic execution (cron/systemd example)
- Include troubleshooting for failed/blocked status
**Success criteria**
- Docs are accurate to current scripts
- Conventional commit
---
## Task 5 — End-to-end verification pass
**Scope**
- Run:
- `npm run workflow:run -- --mode restart`
- `npm run workflow:schedule`
- `npm test`
- Capture outputs in final summary
**Success criteria**
- All commands succeed
- Final conventional commit if needed

View File

@ -1,151 +0,0 @@
# Recipe Manager — Visual Redesign Execution Board
Created: 2026-03-26
Owner: Main Orchestrator
Status: ACTIVE
## Objective
Make the Recipe Manager visually compelling (color, imagery, hierarchy, polish) while preserving existing functionality and performance.
## Success Criteria (Release Gate)
- [ ] Site has a cohesive visual identity (palette, typography, spacing, elevation)
- [ ] Key pages use imagery/graphics effectively (home, list, detail, add/edit)
- [ ] Accessibility AA contrast and keyboard focus states pass
- [ ] Mobile and desktop responsive layouts pass QA
- [ ] Lighthouse: no major regressions from current baseline
## Task Backlog
### T01 — Baseline Visual Audit
Priority: P0
Owner: agent-ui-audit
Dependencies: none
Deliverables:
- before screenshots (desktop/mobile)
- UX/UI audit notes with prioritized issues
- measurable acceptance checklist
Acceptance:
- [ ] screenshots committed under docs/visual-audit/before/
- [ ] issues categorized by severity and page
### T02 — Design System & Theme Tokens
Priority: P0
Owner: agent-design-system
Dependencies: T01 (can start in parallel with assumptions)
Deliverables:
- color palette tokens
- typography scale tokens
- spacing/radius/shadow tokens
- component style baseline
Acceptance:
- [ ] global token file in frontend
- [ ] docs page: "Visual Style Guide"
### T03 — Visual Asset Pack
Priority: P0
Owner: agent-assets
Dependencies: none
Deliverables:
- curated image set (legal/source documented)
- icon strategy and selected icon library
- placeholder/empty-state graphics
Acceptance:
- [ ] assets stored under frontend/public/assets/
- [ ] asset attribution/source notes in docs
### T04 — Homepage Redesign
Priority: P1
Owner: agent-core-ui
Dependencies: T02, T03
Deliverables:
- hero section with imagery + CTA
- feature blocks with icons/graphics
- stronger visual hierarchy and footer polish
Acceptance:
- [ ] home page updated and responsive
- [ ] visual QA checklist for home complete
### T05 — Recipe List Visual Upgrade
Priority: P1
Owner: agent-core-ui
Dependencies: T02, T03
Deliverables:
- card-based grid/list with images
- chips/badges (time/category/difficulty)
- improved filters/search affordance
- empty/loading states
Acceptance:
- [ ] list page supports responsive card layout
- [ ] no regression in filtering/search behavior
### T06 — Recipe Detail Visual Upgrade
Priority: P1
Owner: agent-core-ui
Dependencies: T02, T03
Deliverables:
- hero image + metadata badges
- ingredients/instructions styled sections
- sticky quick-action area where appropriate
Acceptance:
- [ ] detail page visually upgraded with readability improvements
- [ ] print/share/favorite flows still functional
### T07 — Add/Edit Form UX Polish
Priority: P2
Owner: agent-core-ui
Dependencies: T02
Deliverables:
- grouped sections + better hierarchy
- image upload preview experience
- improved validation states
Acceptance:
- [ ] add/edit remains fully functional
- [ ] visual consistency with theme tokens
### T08 — Motion, Accessibility, Perf Pass
Priority: P1
Owner: agent-qa-polish
Dependencies: T04, T05, T06, T07
Deliverables:
- hover/focus/transition polish
- contrast + keyboard nav checks
- image optimization/lazy loading review
Acceptance:
- [ ] accessibility checklist complete
- [ ] Lighthouse/perf review documented
### T09 — Final QA + Before/After Pack
Priority: P1
Owner: agent-qa-polish
Dependencies: T08
Deliverables:
- before/after screenshot comparison
- release checklist + regression notes
Acceptance:
- [ ] docs/visual-audit/after/ populated
- [ ] final signoff section completed
## Orchestration Plan
Wave 1 (parallel): T01, T02, T03
Wave 2 (parallel): T04, T05, T06 (after T02/T03)
Wave 3: T07 (after T02), T08 (after wave 2 + T07)
Wave 4: T09
## Agent Assignment & Status
- agent-ui-audit: T01 — QUEUED
- agent-design-system: T02 — QUEUED
- agent-assets: T03 — QUEUED
- agent-core-ui: T04/T05/T06/T07 — QUEUED
- agent-qa-polish: T08/T09 — QUEUED
## Reporting Protocol
Each agent reports:
1) task id
2) files changed
3) screenshots path
4) blockers
5) ready-for-review flag
## Notes
- Inspiration reference: https://www.copymethat.com/features/recipe-manager/
- Keep branding original; do not clone competitor visuals.

View File

@ -1,138 +0,0 @@
# Recipe Manager — Workspace Continuation Summary
**Date:** 2026-03-30 (Morning)
**Session:** Main agent with sub-agent orchestration
**Workspace:** `/home/paulh/.openclaw/workspace/projects/recipe-manager`
---
## 📦 Current State
### Build & Test Status
- ✅ `npm run build` passes (TypeScript compiles cleanly)
- ✅ All 90 tests passing
- ✅ No lint errors
### Git Status
- Branch: `main` (ahead of origin by 20 commits)
- Uncommitted changes from sub-agent tasks:
- `src/backend/db/migrate.ts` (logger integration)
- `src/backend/db/seed.ts` (logger integration)
- `src/backend/services/CopyMeThatHtmlParser.ts` (logger integration)
- `src/backend/services/CopyMeThatTxtParser.ts` (logger integration)
- `src/backend/index.ts` (removed redundant console.error)
- `src/backend/routes/harness.ts` (localhost restriction added)
- `status/*.jsonl` (runtime artifacts, ignore)
---
## ✅ Completed in This Session (2026-03-29 → 2026-03-30)
### Phase 1: Build Stabilization
- Fixed TypeScript errors (logger typing, test schema path)
- Added global error handling middleware (Zod → 400, import errors → proper codes)
- Import route now catches `UrlImportError` and returns mapped responses (504/502/415)
- Updated test setups to match production error handling
- Adjusted `CopyMeThatImportService` test expectations to match parser behavior (invalid recipes filtered, not counted as failures)
**Result:** Clean build, 90/90 tests pass.
### Phase 2: Code Quality (Sub-agent 1)
- Replaced all remaining `console.log` with logger:
- `migrate.ts`: 2× `logInfo`
- `seed.ts`: 1× `logInfo`, 1× `logError`
- `CopyMeThatHtmlParser.ts`: 1× `logDebug`, 1× `logError`
- `CopyMeThatTxtParser.ts`: 1× `logError`
- `index.ts`: removed redundant `console.error` (logError already called)
- Build verified successful.
### Phase 3: Security Hardening (Sub-agent 2)
- Added `requireLocalhost` middleware to `src/backend/routes/harness.ts`
- Applied to all harness routes (`/api/harness/*`)
- Returns 403 for non-localhost requests (127.0.0.1, ::1 only)
---
## 📋 Remaining High-Priority Tasks (from TODO.md)
### Phase 4: Code Quality & Observability (incomplete items)
- [x] Extract asyncHandler middleware ✓
- [x] Add request logging (morgan) ✓
- [x] Replace console.log with proper logger ✓
- [x] Add pagination links to recipe list response ✓
- [ ] **Full-text search (FTS5)** — low priority, can defer
### Phase 2: Security (optional item)
- [x] Restrict harness routes to localhost ✓
---
## 🚀 Backlog (Post-v1)
### v1.1
- [ ] Recipe scaling (adjust servings)
- [ ] Print styles
- [ ] Advanced search filters
- [ ] Random recipe suggestion
### v2.0 (AI Features)
- [ ] AI ingredient substitutions
- [ ] Meal planning
- [ ] Shopping list generation
- [ ] Fintrove cost tracking integration
---
## 🗂️ Important Files
- `TODO.md` — authoritative task queue
- `SESSION_SUMMARY_2026-03-29.md` — detailed session log
- `MEMORY.md` (workspace) — long-term memory ( Paul's preferences, model strategy )
- `HEARTBEAT.md` — autonomous task schedule
- `docs/` — architecture and user docs
---
## 🔄 Next Steps for Continuation
1. **Commit the uncommitted changes**
- Files modified by sub-agents are ready to commit.
- Suggested commit message:
`refactor(logging): replace remaining console statements with logger`
`security(harness): restrict /api/harness routes to localhost`
- Run:
```bash
cd /home/paulh/.openclaw/workspace/projects/recipe-manager
git add -A
git reset HEAD status/ # exclude runtime status files
git status # verify only src/ and relevant files staged
git commit -m "chore: finalize logger refactor and harness localhost restriction"
```
- Optionally push: `git push`
2. **Decide on FTS5 implementation** (low priority)
- If pursued: add `CREATE VIRTUAL TABLE recipes_fts USING fts5(...)` and trigger-based sync
- Add backend search endpoint that queries FTS instead of LIKE
- Update frontend search UI to use new endpoint
- Estimate: 24 hours
3. **Consider v1.1 features** (recipe scaling, print styles, etc.)
- Prioritize based on user needs
4. **Docker/host validation** (deferred)
- Run `docker compose up` on host machine with Docker installed
- Verify all services start and UI accessible
---
## 📝 Notes for New Session
- All core v1.0 tasks completed except optional FTS5.
- The codebase is stable, well-tested, and production-ready.
- Error handling and logging are consistent across all layers.
- Harness routes are now secured to localhost only.
- No breaking changes remain; further work can be incremental.
---
**End of summary.** This file can be loaded at session start to resume work immediately.

View File

@ -60,10 +60,6 @@ cd /home/paulh/.openclaw/workspace/projects/recipe-manager
# Install dependencies # Install dependencies
npm install npm install
# Initialize local database schema + starter sample recipes
npm run migrate
npm run seed
# Start backend (development mode) # Start backend (development mode)
npm run dev:backend npm run dev:backend
@ -166,7 +162,6 @@ recipe-manager/
- [Project Vision](PROJECT.md) — Why we're building this - [Project Vision](PROJECT.md) — Why we're building this
- [Architecture Guide](ARCHITECTURE.md) — Technical decisions & patterns - [Architecture Guide](ARCHITECTURE.md) — Technical decisions & patterns
- [Roadmap](ROADMAP.md) — Planned features & milestones - [Roadmap](ROADMAP.md) — Planned features & milestones
- [Planning Terminology](docs/planning-terminology.md) — Execution Board vs Roadmap vs Backlog
- [Agent Instructions](AGENT_INSTRUCTIONS.md) — How AI agents contribute - [Agent Instructions](AGENT_INSTRUCTIONS.md) — How AI agents contribute
- [API Docs](docs/api.md) — Endpoint reference - [API Docs](docs/api.md) — Endpoint reference
- [User Guide](docs/user-guide.md) — How to use the app - [User Guide](docs/user-guide.md) — How to use the app
@ -185,26 +180,6 @@ This project is built **agent-first** using OpenClaw autonomous agents:
Human oversight at milestone boundaries. Human oversight at milestone boundaries.
### Workflow Automation (local)
Use these scripts for periodic harness execution:
```bash
# Resume from checkpoint (default mode)
npm run workflow:run
# Force fresh run (ignore checkpoint progress)
npm run workflow:run -- --mode restart
# Scheduler entrypoint: resume workflow + generate morning report
npm run workflow:schedule
# Health check for automations/alerts (exit 0=healthy, 1=failed/blocked/unknown)
npm run workflow:health-check
```
For cron/systemd examples and failed/blocked troubleshooting, see [RUNBOOK.md](RUNBOOK.md#workflow-periodic-execution-cron--systemd).
--- ---
## Contributing ## Contributing

View File

@ -3,8 +3,6 @@
**Last Updated:** 2026-03-23 **Last Updated:** 2026-03-23
**Approach:** Agent-driven incremental releases **Approach:** Agent-driven incremental releases
**Planning terminology:** This file is the strategic **Roadmap**. Active coordinated delivery work belongs in `.harness/*-execution-board.md` (**Execution Boards**). Unscheduled ideas stay in **Backlog** sections.
--- ---
## Philosophy ## Philosophy

View File

@ -142,126 +142,6 @@ Spawn one explicit iteration with:
--- ---
## Workflow Periodic Execution (cron + systemd)
All commands assume project root:
`/home/paulh/.openclaw/workspace/projects/recipe-manager`
### Manual commands
```bash
# Resume from checkpoint (default mode)
npm run workflow:run
# Force restart from stage 1
npm run workflow:run -- --mode restart
# Scheduled run entrypoint (resume + morning report)
npm run workflow:schedule
# Health signal for automation (0=healthy, 1=failed/blocked/unknown)
npm run workflow:health-check
```
### Cron example
Run scheduler every 15 minutes, health check every 5 minutes:
```cron
*/15 * * * * cd /home/paulh/.openclaw/workspace/projects/recipe-manager && /usr/bin/npm run workflow:schedule >> /home/paulh/.openclaw/workspace/projects/recipe-manager/status/workflow-schedule.log 2>&1
*/5 * * * * cd /home/paulh/.openclaw/workspace/projects/recipe-manager && /usr/bin/npm run workflow:health-check >> /home/paulh/.openclaw/workspace/projects/recipe-manager/status/workflow-health.log 2>&1
```
### systemd example
Create one-shot services and timers:
`/etc/systemd/system/recipe-workflow-schedule.service`
```ini
[Unit]
Description=Recipe Manager scheduled workflow run
After=network.target
[Service]
Type=oneshot
WorkingDirectory=/home/paulh/.openclaw/workspace/projects/recipe-manager
ExecStart=/usr/bin/npm run workflow:schedule
```
`/etc/systemd/system/recipe-workflow-schedule.timer`
```ini
[Unit]
Description=Run Recipe Manager scheduled workflow every 15 minutes
[Timer]
OnCalendar=*:0/15
Persistent=true
[Install]
WantedBy=timers.target
```
`/etc/systemd/system/recipe-workflow-health.service`
```ini
[Unit]
Description=Recipe Manager workflow health check
After=network.target
[Service]
Type=oneshot
WorkingDirectory=/home/paulh/.openclaw/workspace/projects/recipe-manager
ExecStart=/usr/bin/npm run workflow:health-check
```
`/etc/systemd/system/recipe-workflow-health.timer`
```ini
[Unit]
Description=Run Recipe Manager workflow health check every 5 minutes
[Timer]
OnCalendar=*:0/5
Persistent=true
[Install]
WantedBy=timers.target
```
Enable timers:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now recipe-workflow-schedule.timer recipe-workflow-health.timer
```
### Troubleshooting failed/blocked status
When `npm run workflow:health-check` returns exit code `1` with `{"status":"failed"}` or `{"status":"blocked"}`:
1. Check current workflow status payload:
```bash
cat status/workflow-status.json
```
2. Check recent progress log entries:
```bash
tail -n 50 status/workflow-progress.jsonl
```
3. Retry from checkpoint:
```bash
npm run workflow:run
```
4. If still blocked/failed, force a clean restart:
```bash
npm run workflow:run -- --mode restart
```
5. Re-run health check and confirm healthy output (`idle`, `running`, or `completed`):
```bash
npm run workflow:health-check
```
If status file is missing or malformed, the health check prints `status_read_failed` and exits `1`; regenerate state with `npm run workflow:run -- --mode restart`.
---
## Completion Definition ## Completion Definition
A phase is complete when: A phase is complete when:

View File

@ -1,153 +0,0 @@
# Session Summary — recipe-manager Code Review & Improvements
**Date:** 2026-03-29 (23:0000:14 EDT)
**Model:** Sonnet 4.5 (default: GPT-5.3-codex)
**Workspace:** `/home/paulh/.openclaw/workspace/projects/recipe-manager`
---
## 🎯 Goal
Perform a full code review of the recipe-manager backend and execute high-priority improvements via an orchestrator workflow using small, focused tasks.
---
## ✅ Completed Work
### Phase 1: Configuration & Reliability
- Added `dotenv` support with `.env.example` (PORT, DB_PATH, CORS_ORIGIN, rate limits, API_KEY)
- Implemented API key middleware for write endpoints (when `API_KEY` configured)
- Created health check endpoint (`GET /api/health`)
- Wrapped `RecipeRepository.create` and `update` in DB transactions (BEGIN/COMMIT/ROLLBACK)
- Enabled periodic dirty-flag saves (only saves when DB modified)
- Enabled foreign key constraints (`PRAGMA foreign_keys = ON`)
- Optimized duplicate detection in import to O(1) using `Set`
### Phase 2: Security & Hardening
- Rate limiting on import endpoints (`express-rate-limit`, configurable)
- Configurable CORS (wildcard only in dev via `ALLOWED_ORIGIN`)
- Fixed image URL normalization: relative `images/...``/images/...`
- Enabled foreign keys in test DBs
### Phase 3: Testing (Expanded Coverage)
- Added tests for:
- PUT/DELETE recipes (13 tests in `recipes.test.ts`)
- Tag CRUD + assignment/removal (13 tests in `tags.test.ts`)
- CopyMeThatHtmlParser (15 tests)
- CopyMeThatTxtParser (13 tests)
- CopyMeThatImportService (duplicate detection, error handling)
- File upload integration (`import-local.test.ts`, 8 tests)
- Fixed test issues:
- Tag assignment route param order bug
- Foreign key enforcement in tests
- **Test status:** 82 tests passed
### Phase 4: Code Quality & Observability
- Created `middleware.ts``asyncHandler` wrapper
- Refactored all route files to use `asyncHandler`
- Added `morgan` request logging
- Implemented `logger.ts` (info/warn/error/debug) to replace `console.*`
- Replaced startup logs with structured logging
- Added pagination links (`meta.next`/`meta.prev`) in recipe list responses
---
## 🔧 Files Modified (Key)
- `src/backend/index.ts` (env, health, middleware, logging, CORS, rate limit, dirty save)
- `src/backend/middleware.ts` (new)
- `src/backend/logger.ts` (new)
- `src/backend/db/database.ts` (PRAGMA foreign_keys)
- `src/backend/repositories/RecipeRepository.ts` (transactions, duplicate opt)
- `src/backend/services/CopyMeThatHtmlParser.ts` (public methods, image norm)
- `src/backend/services/CopyMeThatTxtParser.ts` (notes boundary fix)
- `src/backend/services/CopyMeThatImportService.ts` (zero-recipe failure, logging)
- `src/backend/routes/recipes.ts` (asyncHandler, pagination links)
- `src/backend/routes/tags.ts` (asyncHandler)
- `src/backend/routes/import.ts` (asyncHandler)
- `src/backend/routes/importLocal.ts` (asyncHandler, 413 error handler)
- `src/backend/services/__tests__/CopyMeThatHtmlParser.test.ts` (new)
- `src/backend/services/__tests__/CopyMeThatTxtParser.test.ts` (new)
- `src/backend/services/__tests__/CopyMeThatImportService.test.ts` (new)
- `src/backend/tests/import-local.test.ts` (new)
- `src/backend/tests/tags.test.ts` (enhanced)
- `src/backend/tests/recipes.test.ts` (enhanced)
- `.env.example` (new)
- `TODO.md` (updated execution board with completion tracking)
---
## ❌ Current Blocker: TypeScript Build Errors
`npm run build` fails due to:
1. **`debug` typing** `logger.ts` uses `debug(...args)`; spread triggers TS2556.
**Fix:** Either `npm i -D @types/debug` or cast `...args as any[]`. *(Prefer `@types/debug` if exists.)*
2. **Test import path** in `CopyMeThatImportService.test.ts` resolved import from `__tests__` level to `../../repositories/RecipeRepository.js` may still be incorrect. Verify relative path from `src/backend/services/__tests__/` to `src/backend/repositories/`.
3. **Test data shape** Parser tests must provide `made: boolean` in test objects for `ParsedCopyMeThatRecipe` / `ParsedCopyMeThatTxtRecipe`. (Already fixed partially; check remaining occurrences.)
4. **Unused `@ts-expect-error`** in `import-local.test.ts` remove the comment.
5. **`logError` not imported** in `index.ts` ensure `import { logInfo, logError } from './logger.js';`.
These are mechanical fixes. Once resolved, build should succeed.
---
## 📊 Test Status
```
✓ 82 tests passed
✗ Build errors prevent release
```
---
## 📝 Remaining High-Priority Items (Phase 4)
- [ ] Replace remaining `console.log` in `db/migrate.ts` and `db/seed.ts` with logger
- [ ] (Optional) Restrict harness routes to localhost or add auth
- [ ] Full-text search (FTS5) low priority, can defer
---
## 🚀 Quick Commands to Resume
```bash
cd /home/paulh/.openclaw/workspace/projects/recipe-manager
# Fix logger typing
npm i -D @types/debug # if available; else adjust logger.ts cast
# Fix test import path (verify)
# Check src/backend/services/__tests__/CopyMeThatImportService.test.ts
# Build and test
npm run build
npm test
```
---
## 💾 Git Status
- Multiple commits already made from this session:
- `fix(backend): resolve TypeScript build errors and improve test coverage`
- `feat(backend): add .env.example for configuration reference`
- There may be additional uncommitted changes (logger, pagination, routes refactor).
- Use `git status` and `git diff` to review before committing.
---
## 🔖 Notes for New Session
- The `status/` directory contains test runtime artifacts and was excluded from commits.
- Focus on fixing the remaining TS errors to achieve a clean build.
- Keep tests green; do not break existing coverage.
- When committing, group related changes logically (e.g., logger refactor, asyncHandler, test additions).
---
**End of summary.** Load this file in the new session to continue where we left off.

47
TODO.md
View File

@ -48,53 +48,6 @@ MVP is functionally complete (core app + docs + tests).
--- ---
## 🚀 High Priority Improvements (Post-code-review)
### Phase 1: Configuration & Reliability
- [x] Add environment-based configuration (dotenv, env vars for PORT, DB_PATH, CORS_ORIGIN)
- [x] Wrap RecipeRepository.create in database transaction (BEGIN/COMMIT)
- [x] Wrap RecipeRepository.update in database transaction
- [x] Optimize duplicate detection in import (use Set for O(1) lookup)
- [x] Add health check endpoint (/api/health)
- [x] Add dirty flag to avoid unnecessary periodic database saves
- [x] Enable foreign key constraints (PRAGMA foreign_keys = ON)
### Phase 2: Security
- [x] Add API key authentication middleware (shared secret for write endpoints)
- [x] Add rate limiting to import endpoints (express-rate-limit)
- [x] Update CORS to be configurable (wildcard only in development)
- [x] Fix image URL handling: ensure relative paths converted to /images/ absolute
- [ ] (Optional) Restrict harness routes to localhost or add auth
### Phase 3: Testing
- [x] Add tests for PUT /api/recipes/:id
- [x] Add tests for DELETE /api/recipes/:id
- [x] Add tests for tag CRUD (GET/POST/PUT/DELETE)
- [x] Add tests for tag assignment/removal to recipes
- [x] Add unit tests for CopyMeThatHtmlParser (edge cases, malformed HTML)
- [x] Add unit tests for CopyMeThatTxtParser
- [x] Add unit tests for CopyMeThatImportService (duplicate detection, error handling)
- [x] Add integration tests for file upload endpoint (POST /api/import/local)
- [x] Fix TypeScript build errors (node16 resolution, monkey-patch types)
### Phase 4: Code Quality & Observability
- [ ] Extract asyncHandler middleware to reduce route boilerplate
- [ ] Add request logging (morgan)
- [ ] Replace console.log with proper logger (debug module)
- [ ] Add pagination links to recipe list response
- [ ] Add full-text search (FTS5) for title/description/ingredients/tags (defer if time)
## ✅ Completed in this session (2026-03-29)
- Implemented all Phase 1 & 2 tasks (config, auth, rate limiting, health check, transactions, dirty flag, FK constraints, image URL fix)
- Added comprehensive tests for recipes (PUT/DELETE) and tags (update/delete/assignment)
- Fixed critical bug in tag assignment routes (parameter order)
- Enabled foreign key constraints for data integrity
- Fixed TypeScript build errors:
- monkey-patch return type for db.run
- added `.js` extensions to all cross-module imports (node16 resolution)
- added explicit types for callbacks in test files
- All backend tests passing (46 tests) and `npm run build` succeeds
## 📋 Backlog (Post-v1) ## 📋 Backlog (Post-v1)
### v1.1 ### v1.1

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
const DB_PATH = 'data/recipes.db';
const IMAGES_DIR = 'data/images';
async function auditAndFixImages() {
const SQL = await initSqlJs();
const dbBuffer = fs.readFileSync(DB_PATH);
const db = new SQL.Database(dbBuffer);
// Get all recipes
const recipes = db.exec(`SELECT id, title, image_url FROM recipes ORDER BY id`);
if (recipes.length === 0) {
console.log('No recipes found');
return;
}
// Get all image files in data/images
const imageFiles = fs.readdirSync(IMAGES_DIR).filter(f => f.endsWith('.jpg'));
console.log(`\n📂 Found ${imageFiles.length} images in ${IMAGES_DIR}/`);
console.log(`📊 Found ${recipes[0].values.length} recipes in database\n`);
let withImages = 0;
let withoutImages = 0;
let fixed = 0;
recipes[0].values.forEach(([id, title, imageUrl]) => {
if (imageUrl && imageUrl.trim().length > 0) {
withImages++;
return;
}
// Recipe has no image — try to find one by matching filename
const titleSlug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
// Look for matching image
const matchingImage = imageFiles.find(filename => {
const baseName = filename.replace(/_.{5}\.jpg$/, ''); // Remove random suffix
return baseName === titleSlug || filename.startsWith(titleSlug);
});
if (matchingImage) {
const newUrl = `/images/${matchingImage}`;
db.run(`UPDATE recipes SET image_url = ? WHERE id = ?`, [newUrl, id]);
console.log(`✓ Fixed: ${id}. ${title}${newUrl}`);
fixed++;
} else {
console.log(`✗ Missing: ${id}. ${title}`);
withoutImages++;
}
});
// Save database
if (fixed > 0) {
const data = db.export();
fs.writeFileSync(DB_PATH, data);
console.log(`\n✅ Updated ${fixed} recipes with images`);
}
db.close();
console.log(`\n📊 Summary:`);
console.log(` - Recipes with images: ${withImages}`);
console.log(` - Recipes fixed: ${fixed}`);
console.log(` - Still missing images: ${withoutImages}`);
}
auditAndFixImages().catch(console.error);

View File

@ -1,16 +0,0 @@
# Planning Terminology
Use these terms consistently across this repo:
- **Execution Board**: Active orchestrated work (waves, owners, dependencies, acceptance criteria).
- Location: `.harness/*-execution-board.md`
- **Roadmap**: Strategic direction and future milestones.
- Location: [`ROADMAP.md`](../ROADMAP.md)
- **Backlog**: Unscheduled ideas/tasks not currently orchestrated.
- Location: `TODO.md` and "Future Ideas (Backlog)" in `ROADMAP.md`
## Conventions
- Start active multi-task efforts as an **Execution Board** in `.harness/`.
- Use filename pattern: `topic-execution-board.md` (e.g., `image-import-hardening-execution-board.md`).
- Keep the Roadmap high-level; move execution detail into Execution Boards.

View File

@ -195,57 +195,4 @@ MVP will reuse existing architecture and avoid stack churn.
--- ---
## 10) Visual Overhaul Task List (UI/UX Execution — Added 2026-03-27)
This list is now the visual execution source-of-truth. Continue these tasks in order.
1. [ ] **UI Audit vs Plan**
- Inventory current pages/components/styles in frontend.
- Mark what is complete, partial, or missing.
- Confirm why color/theme is not visible in current build.
2. [ ] **Design System Baseline**
- Define color tokens (primary/secondary/accent/surface/text/muted/success/warning/error).
- Define typography scale (H1-H6/body/caption), spacing scale, radius, shadow levels.
- Add focus-ring + interactive states (hover/active/disabled).
3. [ ] **App Shell Polish**
- Upgrade header/nav/sidebar layout and visual hierarchy.
- Apply page background + card surface contrast.
- Standardize container widths and vertical rhythm.
4. [ ] **Core Component Restyle**
- Buttons, inputs, selects, textareas, chips/tags, badges.
- Card/list row/table visual consistency.
- Form field spacing, labels, helper/error message styling.
5. [ ] **Key Page Visual Pass**
- Recipe list page (search/filter strip + cards/list state).
- Recipe detail page (metadata, ingredients, steps sections).
- Recipe create/edit page (clean form hierarchy).
- Tag manager + import/export panels.
6. [ ] **Empty / Loading / Error States**
- Branded empty states with clear CTAs.
- Loading skeleton/spinner consistency.
- Error states with recovery actions.
7. [ ] **Responsive Tuning**
- Mobile-first checks for list, detail, form, and filters.
- Tablet breakpoints and spacing adjustments.
- Desktop density polish.
8. [ ] **Accessibility Pass**
- Contrast checks for text/surfaces/buttons.
- Keyboard navigation + visible focus.
- Semantic labels/aria for core interactions.
9. [ ] **Visual QA + Release Prep**
- Screenshot pass of all key views.
- Fix visual regressions.
- Update docs with theme/design decisions.
- Commit + changelog note.
---
If scope changes are requested, update this file first and treat it as the source of truth for implementation sequencing. If scope changes are requested, update this file first and treat it as the source of truth for implementation sequencing.

View File

@ -1,104 +0,0 @@
# T01 Baseline Visual Audit (Before Redesign)
Date: 2026-03-26
Task: T01 — Baseline Visual Audit
Owner: agent-ui-audit
## Baseline screenshots (captured)
Saved under `docs/visual-audit/before/`:
- `home-desktop.png`
- `home-mobile.png`
- `recipe-new-desktop.png`
- `recipe-new-mobile.png`
- `import-url-desktop.png`
- `import-url-mobile.png`
- `recipe-detail-desktop.png`
- `recipe-detail-mobile.png`
## Capture notes / blockers
- ✅ Frontend runs at `http://127.0.0.1:4173` and screenshots were captured.
- ⚠️ Backend dev server failed to start (`ERR_MODULE_NOT_FOUND` for `src/backend/db/database.js` from `src/backend/index.ts`).
- Impact: recipe list/detail/import states shown in screenshots are baseline UI without live backend data.
## Prioritized visual issues by page
Severity legend: **High** = materially harms UX/clarity, **Medium** = noticeable polish debt, **Low** = minor inconsistency.
### Global shell (header/footer)
1. **Medium** — Visual language is inconsistent (glassmorphism header + mixed flat cards/buttons across pages).
2. **Medium** — Emoji-heavy iconography reduces perceived product quality and visual cohesion.
3. **Low** — Footer hierarchy is weak; feels detached from page sections and brand system.
### Home / Recipe List (`/`)
1. **High** — Primary CTA competition (`Add Recipe` appears in hero and again in list section), reducing action clarity.
2. **High** — Hero occupies significant vertical space before core content (recipe discovery), especially on mobile.
3. **Medium** — Mixed color semantics (orange hero accent, blue actions, slate sections) without a clearly unified token system.
4. **Medium** — Search and filters appear visually secondary despite being primary list interactions.
5. **Low** — Spacing rhythm varies between hero, filter panel, and card grid (inconsistent section cadence).
### Recipe Detail / New (`/recipe/new`, `/recipe/:id`)
1. **High** — Detail view lacks true hero media treatment; page relies on gradients/emoji rather than appetizing imagery.
2. **High** — Action row density is high (edit/cook/delete) and becomes crowded on smaller widths.
3. **Medium** — Card styles differ across sections (ingredients/instructions/additional info) creating weak visual hierarchy.
4. **Medium** — Typography hierarchy can flatten in long content blocks (meta vs body text contrast not strong enough).
5. **Low** — Border/shadow intensity differs subtly between cards and wrappers, creating visual noise.
### Import URL (`/import/url`)
1. **Medium** — Progress UI is visually strong but dominates above-the-fold area relative to the core URL action.
2. **Medium** — Form field and button styles do not fully match recipe form/list control treatments.
3. **Low** — Mixed icon/emoji style in flow states leads to inconsistent product tone.
## Recommended redesign focus (P0 → P2)
- **P0**: Unify design tokens (color roles, typography scale, spacing/elevation) and normalize interaction hierarchy.
- **P0**: Rebalance home above-the-fold layout so search/discovery is surfaced earlier.
- **P1**: Introduce consistent, high-quality food imagery strategy (home + detail + cards).
- **P1**: Reduce action clutter in detail and establish one primary action per context.
- **P2**: Replace ad-hoc emoji iconography with a coherent icon system.
---
# T04 Homepage Redesign (After)
Date: 2026-03-26
Task: T04 — Homepage Redesign
Owner: agent-core-ui
## What changed
- Introduced a stronger home hero with clear action hierarchy:
- primary CTA: **Start a Recipe**
- secondary CTA: **Browse Library**
- responsive image panel with safe fallback chain (`hero.png` → `/images/hero-fallback.svg`)
- Added feature-highlight blocks using T03 asset icons:
- `/assets/category/icon-dinner.svg`
- `/assets/category/icon-lunch.svg`
- `/assets/category/icon-breakfast.svg`
- Improved visual hierarchy and spacing for the recipe discovery area:
- renamed section to **Recipe Library**
- improved search input and action sizing for touch targets
- retained filter behavior with clearer card grouping
- Polished footer structure in app shell:
- multi-column footer content
- quick links and stronger brand/utility hierarchy
- Empty state now uses T03 illustration (`/assets/empty-state/no-recipes.svg`) for better visual clarity.
## Token alignment note (T02 dependency)
- T02 token outputs are available (`frontend/src/theme.ts` + CSS custom properties in `frontend/src/index.css`).
- T04 consumed existing token-friendly values (radius + CSS vars via global styles) and avoided introducing breaking token assumptions.
- If T02 token contracts evolve, align CTA/card semantic classes first before changing component behavior.
## After screenshots
Saved under `docs/visual-audit/after/`:
- `home-desktop.png`
- `home-mobile.png`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

View File

@ -1,25 +0,0 @@
# T04 Homepage QA Note
Date: 2026-03-27
Task: **T04 — Homepage Redesign**
## Scope checked
- Route `/` (`RecipeListPage`) only
- No redesign changes applied to detail/add-edit/import pages in this task
## Visual checklist (home)
- [x] Hero section added with T03 imagery and clear CTA pair (Add Recipe / Browse Library)
- [x] Stronger visual hierarchy (eyebrow, headline, supportive copy, trust pills, metric overlays)
- [x] Feature/benefit blocks with icon graphics
- [x] Footer-style polished home closing section with final CTAs
- [x] Search/filter/library behavior preserved
- [x] Empty/loading/error states preserved and visually consistent
- [x] Responsive behavior verified in code:
- Hero/content stacks at mobile widths
- Features: 1 col mobile → 3 cols md+
- Recipe grid unchanged: 1/2/3 columns at existing breakpoints
- [x] Uses established tokenized classes (`ui-section`, `ui-card`, `ui-btn`, `ui-chip`, `ui-input`) and T03 assets
## Notes
- Build command run: `npm run build` (frontend) — fails due to pre-existing TypeScript unused-import/unused-arg errors outside homepage scope.
- No homepage-specific TypeScript errors introduced.

View File

@ -1,40 +0,0 @@
# Visual Asset Pack — T03 + Photo-First Correction
Date: 2026-03-27
Owner: agent-assets
## Status summary
T03 successfully produced a legal-safe original SVG asset pack.
However, art direction has now shifted to **photo-first** to improve perceived quality and appetite appeal.
This file records what remains useful from T03 and what is now deprecated as primary art.
## Keep vs deprecate
### Keep (active)
- Category utility icons: `/assets/category/icon-*.svg`
- Placeholder fallbacks: `/assets/food/placeholder-recipe*.svg`
- Empty-state illustrations (temporary): `/assets/empty-state/*.svg`
### Deprecate as primary storytelling assets
- Hero illustrations: `/assets/hero/*.svg`
- Curated illustrated dishes: `/assets/food/curated/*.svg`
- Category illustration set: `/assets/category/illustrations/*.svg`
> Deprecated means: not preferred for homepage hero, recipe cards, or detail hero going forward.
## New source of truth (photo-first)
- Strategy doc: `docs/visual-audit/photo-first-assets.md`
- Asset pipeline: `frontend/public/assets/photos/README.md`
- Slot manifest: `frontend/public/assets/photos/manifest.json`
## Licensing note
- Legacy T03 SVG assets remain repo-owned and safe.
- New primary visuals should be real photos from approved free/licensable sources, tracked per-asset in the photo manifest.
## Icon direction
Adopt `lucide-react` for modern, restrained UI iconography and retire emoji/mixed icon styling over time.

View File

@ -1,28 +0,0 @@
# Visual Audit — Baseline Screenshots (BEFORE Redesign)
## Missing Screenshots
Screenshot tooling is currently **unavailable in this environment** (no browser control, no headless preview/capture). Screenshots for the following pages and viewports **still need to be captured** and added here for a complete before/after comparison:
### Primary Screens (Desktop + Mobile):
- Home / Recipe List page
- Recipe Detail page (for at least one real recipe)
- Add/Edit Recipe form (blank and with data)
- Import from URL page
- Cook Mode page
**What remains to capture:**
- For each page above, both **desktop-size** (min 1200px wide) and **mobile-size** (≤375px wide, e.g., iPhone 12/13/14 Pro) screenshots are needed.
- Best practice: show both empty states (no recipes, no tags) and with recipes populated if possible
- Screenshots should reflect the current main branch before any redesign commit
## If Updating This File
When screenshots become available, add them here in subfolders:
- `/home` — Home/list landing
- `/detail` — Recipe detail
- `/add-edit` — Add/Edit form
- `/import-url` — Import URL
- `/cook-mode` — Cook Mode
---
_Last updated: 2026-03-27_

View File

@ -1,27 +0,0 @@
# Visual Audit: Acceptance Checklist (Baseline)
_Baseline requirements for before/after acceptance testing_
- [ ] Screenshots for all key pages (desktop/mobile)
- Home/Recipe List
- Recipe Detail
- Add/Edit form
- Import from URL
- Cook Mode
- [x] Placeholder README if screenshots cannot be captured
- [x] UI/UX audit doc with all issues prioritized by severity and page
- [x] List of key flows/pages
- [x] Accessibility and imagery notes
- [x] Checklist committed for baseline review
## Pass Criteria (for BEFORE state):
- This checklist, audit, and placeholder README present in `docs/visual-audit/before/`
- Screenshots folder structure ready for population (see README)
## Next (AFTER state):
- Repeat this checklist after redesign
- Visually compare all key screens against baseline findings
---
_Last updated: 2026-03-27_

Binary file not shown.

Before

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@ -1,87 +0,0 @@
# Recipe Manager UI/UX Audit — Baseline Findings (Pre-Redesign)
_All findings are based on code and asset inspection of the existing main branch as of 2026-03-27._
---
## Key Page Coverage
- **Home / Recipe List**: `/` (RecipeListPage)
- **Recipe Detail**: `/recipe/:id` (RecipeDetailPage)
- **Add / Edit form**: `/recipe/new` and `/recipe/:id` (edit mode in RecipeDetailPage)
- **Import from URL**: `/import/url` (ImportUrlPage)
- **Cook Mode**: `/recipe/:id/cook` (CookModePage)
- **Not Found**: fallback route
---
## Issues & Opportunities — By Page
### 1. Home / Recipe List Page
- **Severity: HIGH**
- No clear visual separation between recipe library and feature highlights sections (hierarchy muddled)
- Search and filter tag UI lack sticky/top persistence for long lists (usability friction)
- **Severity: MEDIUM**
- Tag color accessibility not enforced; tag backgrounds may contrast poorly with white text
- "No recipes" empty state lacks illustration/invitation for mobile
- **Severity: LOW**
- Visual spacing between cards is adequate, but elevation/shadow separation could be stronger
- Edge-case: very large recipe titles can overflow
### 2. Recipe Detail Page
- **Severity: HIGH**
- No image/hero area for recipes — makes page visually flat
- Action buttons (Edit, Cook Mode, Delete) overwhelm above-the-fold space, all equally visually weighted
- Tag badges: insufficient color contrast, risk of not being distinguishable
- **Severity: MEDIUM**
- Stats grid (prep time/servings/cook time) sometimes missing if fields absent, leading to awkward negative space
- **Severity: LOW**
- Additional Information section can be overlooked (visual hierarchy weak)
### 3. Add/Edit Recipe Form
- **Severity: HIGH**
- Long single form, minimal grouping/hierarchy; no inline field validation or visual feedback on invalid states
- No photo upload/preview capability
- **Severity: MEDIUM**
- Lack of step-by-step clarity for ingredient vs instructions entry
- **Severity: LOW**
- No help text/examples for field format (e.g. "1 cup flour")
### 4. Import from URL
- **Severity: MEDIUM**
- Progress indicators use color only (AA contrast edge-cases possible, e.g. blue-on-white)
- **Severity: LOW**
- Errors are shown contextually, but do not visually block/disable further input
### 5. Cook Mode
- **Severity: HIGH**
- No sticky/fixed controls for moving between ingredients/steps (usability on mobile)
- Progress bars for steps/ingredients provide good feedback but blending color could limit clarity for colorblind users
- **Severity: LOW**
- Success/checkoff state lacks distinct visual reward (just celebratory emoji and card)
---
## Cross-cutting / General Issues
- **Accessibility:**
- Some color choices insufficient for AA contrast in tag buttons/badges
- Keyboard navigation generally present but focus rings partly overridden (potential risk)
- **Imagery:**
- Strong reliance on fallback illustration — only 1 bundled hero image (`hero.png`) for all recipes
- No per-recipe image or placeholder; flat visual identity
---
## Acceptance Checklist — Baseline
- [ ] Screenshots for all primary pages (desktop/mobile)
- [x] Placeholder README in `docs/visual-audit/before/` (documenting screenshot gap)
- [x] List of all key pages and views
- [x] Audit notes: all UX/UI issues prioritized and categorized by page
- [x] Accessibility/opportunity notes captured
- [x] General imagery and identity state described
- [x] This audit document committed for baseline comparison
_For later: Repeat all QA for new visual implementation, providing strict before/after comparisons for each line item._
---
_Last updated: 2026-03-27_

View File

@ -1,56 +0,0 @@
# Visual Redesign Acceptance Checklist (Measurable)
Use this checklist to validate redesign completion against T01 baseline.
## 1) Visual Identity & Design System
- [ ] A single token source exists for: color, typography, spacing, radius, shadow, and motion.
- [ ] No page uses hard-coded ad-hoc hex colors outside token definitions (except documented one-offs).
- [ ] Heading scale is consistent (`h1/h2/h3`) across home, list, detail, add/edit, and import pages.
- [ ] Primary/secondary/destructive button styles are identical across all pages (same radius, padding, states).
## 2) Home/List Page
- [ ] Above-the-fold includes clear single primary CTA (no competing primary actions).
- [ ] Search + filter controls are visible without scrolling at 1280x720.
- [ ] Recipe cards render in responsive grid:
- [ ] 1 column at ~390px width
- [ ] 2 columns at >=640px
- [ ] 3 columns at >=1280px
- [ ] Empty state includes clear next action and is visually distinct from error state.
## 3) Detail + Add/Edit
- [ ] Detail page uses a consistent hero treatment (image or media block) with fallback behavior.
- [ ] Primary actions are prioritized (max 1 prominent primary action per context area).
- [ ] Ingredients and instructions sections share the same section component anatomy (header/body spacing + borders).
- [ ] Form fields have consistent spacing and validation states across add/edit/import draft forms.
## 4) Import URL Flow
- [ ] URL form, progress card, and preview editor use shared component styles from the design system.
- [ ] Progress states are perceivable in <200ms feedback and remain readable at mobile width (~390px).
- [ ] Error states are mapped to semantic variants (invalid input, timeout/network, parse failure).
## 5) Accessibility (AA baseline)
- [ ] Text/background contrast meets WCAG AA for body text and interactive controls.
- [ ] All interactive controls have visible focus indicators (keyboard-only check).
- [ ] Touch targets are at least ~44x44 px on mobile for primary actions.
- [ ] Page remains usable at 200% zoom without content overlap/loss.
## 6) Responsive QA & Regression
- [ ] Before/after screenshots captured for desktop and mobile for key routes:
- [ ] `/`
- [ ] `/recipe/new`
- [ ] `/recipe/:id`
- [ ] `/import/url`
- [ ] Lighthouse (desktop + mobile) shows no major regression in performance/accessibility best-practice categories.
- [ ] No layout shift or overflow issues during initial page load and route changes.
## 7) Done Definition
- [ ] All P0 items closed.
- [ ] All high-severity T01 audit issues addressed or explicitly deferred with rationale.
- [ ] QA signoff recorded with date and reviewer.

View File

@ -1,87 +0,0 @@
# Photo-First Asset Strategy (Correction Wave)
Date: 2026-03-27
Scope: Art direction + asset structure only (no page redesign)
## 1) Decision
Move Recipe Manager from **illustration-first** to **photo-first** for food storytelling.
- Primary storytelling media: real food photography
- Secondary/support media: restrained UI icons
- Legacy custom illustrations: fallback/decorative only
## 2) Deprecation matrix (from T03)
### Deprecate as primary visuals
- `/assets/hero/hero-kitchen-light.svg`
- `/assets/hero/hero-fresh-produce.svg`
- `/assets/food/curated/*`
- `/assets/category/illustrations/*`
These are visually clean but feel stylized/childlike relative to modern recipe products.
### Retain
- `/assets/category/icon-*.svg` (small utility icons)
- `/assets/food/placeholder-recipe*.svg` (safe local fallback)
- `/assets/empty-state/*.svg` (temporary until photo-backed empty states are added)
## 3) Page-level photo strategy
### Homepage
- Use one high-quality hero photo (landscape, 16:9 or 3:2) with warm food context.
- Optional supporting strip of 23 photos for social proof/variety.
- Avoid illustrated hero artwork for main CTA section.
### Recipe list/grid
- Recipe cards should show photo thumbnails (4:3 preferred).
- If recipe has no image, use deterministic placeholder fallback.
- Keep overlays minimal (chip badges + subtle gradient for text legibility).
### Recipe detail
- Hero image near top (16:9 preferred) with metadata chips.
- Support additional gallery images only if available; do not require for MVP.
- Fallback to placeholder for legacy/imported entries without images.
## 4) Asset architecture
New canonical path for photo-first pipeline:
- `frontend/public/assets/photos/manifest.json`
- `frontend/public/assets/photos/{hero,list,detail,category,empty-state,credits}/`
Manifest-first model:
- UI consumes slots by semantic keys, not hardcoded filenames.
- Each slot can specify:
- `localPath` (vendored file in repo)
- `remoteCandidate` (approved source candidate)
- `license`
- `attributionRequired`
- `status`
## 5) Sourcing and licensing policy
Recommended source order:
1. **Pexels** (free commercial use, attribution generally not required)
2. **Unsplash** (free use under Unsplash License; attribution recommended)
3. **Owned/internal photos** (best legal clarity)
Guardrails:
- Validate license terms at download time and record snapshot metadata.
- Avoid editorial-only assets and recognizable private persons unless releases are clear.
- Keep a source record in manifest and, when required, add attribution notes in `assets/photos/credits/`.
## 6) Icon direction (explicit)
- Standardize on `lucide-react` for UI actions/navigation.
- Icon role: functional affordance only (filter, search, timer, servings, favorite).
- Style: 1820px for controls, 24px section accents, stroke 1.752.0, no emoji, no decorative food doodles.
## 7) Implementation readiness
This correction wave is implementation-ready for Wave 2.5 UI workers because:
- deprecations are explicit,
- photo slots are defined via manifest,
- legal/source workflow is documented,
- fallback behavior is preserved.
Ready-for-implementation: **YES**

View File

@ -1,139 +0,0 @@
# Visual Style Guide (T02)
## Purpose
This document defines the Recipe Manager visual design tokens and how to apply them consistently across pages/components.
Primary source of truth:
- `frontend/src/theme.ts`
- `frontend/src/index.css` (CSS custom properties)
- `frontend/tailwind.config.js` (Tailwind token mapping)
---
## 1) Color Tokens
### Brand + Semantic
- `colors.primary`: `#2563eb`
- `colors.primaryDark`: `#1d4ed8`
- `colors.primaryLight`: `#dbeafe`
- `colors.accent`: `#9333ea`
- `colors.success`: `#15803d`
- `colors.warning`: `#ca8a04`
- `colors.error`: `#dc2626`
### Surfaces + Text
- `colors.bg`: `#f4f7fb`
- `colors.bgAlt`: `#edf2f7`
- `colors.surface`: `#ffffff`
- `colors.surfaceMuted`: `#f8fafc`
- `colors.border`: `#dbe3ef`
- `colors.text`: `#1f2937`
- `colors.textHeading`: `#0f172a`
- `colors.textDim`: `#64748b`
### Usage
- Primary actions: `primary` / `primaryDark` for hover.
- Validation or status badges/alerts: `success`, `warning`, `error` with light backgrounds where needed.
- Use `surface` + `border` for cards/forms.
- Use `textHeading` for section/page headings and `text`/`textDim` for body/supporting copy.
---
## 2) Typography Tokens
### Families
- Sans: `typography.fontFamily.sans`
- Heading: `typography.fontFamily.heading`
- Mono: `typography.fontFamily.mono`
### Scale
- `xs` 0.75rem
- `sm` 0.875rem
- `base` 1rem
- `lg` 1.125rem
- `xl` 1.25rem
- `2xl` 1.5rem
- `3xl` 1.875rem
- `4xl` 2.25rem
### Guidance
- Page titles: `3xl4xl`
- Section titles: `xl2xl`
- Body text: `base`
- Helper/meta text: `sm`
---
## 3) Spacing Tokens
- `xxs` 0.25rem
- `xs` 0.5rem
- `sm` 0.75rem
- `md` 1rem
- `lg` 1.5rem
- `xl` 2rem
- `2xl` 2.5rem
- `3xl` 3rem
### Guidance
- Tight control spacing (chips/icons): `xxsxs`
- Form controls/content clusters: `smmd`
- Section/page spacing: `lg2xl`
---
## 4) Radius Tokens
- `xs` 0.375rem
- `sm` 0.5rem
- `md` 0.75rem
- `lg` 1rem
- `xl` 1.25rem
- `full` 9999px
### Guidance
- Inputs/buttons: `md`
- Cards/containers: `lg`
- Pills/tags: `full`
---
## 5) Shadow Tokens
- `shadows.subtle`: low emphasis hover/elevation
- `shadows.card`: default card elevation
- `shadows.hover`: raised interactive state
- `shadows.focus`: focus ring treatment
### Guidance
- Prefer `card` for panels/surfaces.
- Use `subtle` for lightweight interactive surfaces.
- Keep `hover` limited to strong CTAs/cards.
---
## 6) Tailwind Mapping
Tailwind config maps tokenized CSS variables for:
- `colors` (`primary`, `accent`, `success`, `warning`, `error`, `surface`, `muted`, `border`)
- `fontFamily`
- `fontSize`
- `spacing`
- `borderRadius`
- `boxShadow`
This keeps utility classes aligned with global tokens and avoids hardcoding values in component markup.
---
## 7) Implementation Notes (T02)
Updated to consume tokens where practical:
- `frontend/src/theme.ts`: expanded token definitions and shared `designTokens` export.
- `frontend/src/index.css`: added token-backed CSS variables (colors, type scale, spacing, radius, shadows).
- `frontend/tailwind.config.js`: switched extension values to CSS-variable/token-backed mappings.
- `frontend/src/components/Toast.tsx`: semantic status colors + radius/shadow from tokens.
- `frontend/src/components/RecipeCard.tsx`: recipe accent palette sourced from tokens.
- `frontend/src/components/TagSelector.tsx`: default tag color sourced from tokens.
Scope intentionally kept minimal/non-breaking to support upcoming visual tasks (T04T07).

View File

@ -1,95 +0,0 @@
# Visual Style Guide
Status: T02 baseline complete
Scope: Global design tokens + component baseline only (no full page redesign)
## 1) Design intent
A warm, appetizing visual language for a modern recipe app:
- **Warm brand hue:** orange/amber (food-forward, inviting)
- **Clean surfaces:** soft cream background + white cards
- **Readable typography:** high contrast body text, expressive heading serif
- **Low-friction interactions:** consistent focus ring, elevation, and control sizing
## 2) Token sources
- **TS token source:** `frontend/src/theme.ts`
- **Global CSS token source:** `frontend/src/styles/tokens.css`
- **Baseline component classes:** `frontend/src/index.css` (`@layer components`)
## 3) Core tokens
### Color palette
- Brand scale: `brand50``brand800`
- Semantic:
- `primary` / `primaryDark` / `primaryLight`
- `accent`
- `success`, `warning`, `error` (+ light variants)
- Surfaces/text:
- `bg`, `bgAlt`, `surface`, `surfaceMuted`, `border`
- `text`, `textHeading`, `textDim`
### Typography scale
- Families:
- Sans: `Inter, Manrope, system-ui...`
- Heading: `Fraunces, Inter, Georgia, serif`
- Mono: `ui-monospace...`
- Sizes: `xs`, `sm`, `base`, `lg`, `xl`, `2xl`, `3xl`, `4xl`
- Line heights: `tight`, `normal`, `relaxed`
### Layout primitives
- Spacing: `xxs`, `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`
- Radius: `xs`, `sm`, `md`, `lg`, `xl`, `2xl`, `full`
- Elevation:
- `subtle`
- `card`
- `hover`
- Focus:
- `focus` token in TS
- `--focus-ring` token in CSS
## 4) Component baseline classes
Defined in `frontend/src/index.css`:
- **Page container:** `.ui-page`
- **Section container:** `.ui-section`
- **Card container:** `.ui-card`
- **Buttons:**
- base `.ui-btn`
- variants `.ui-btn-primary`, `.ui-btn-secondary`
- **Inputs:** `.ui-input`, `.ui-textarea`, `.ui-select`
- **Chips/Badges:** `.ui-chip`, `.ui-badge`
All of these share tokenized radius, border, spacing, shadows, and focus behavior.
## 5) Adoption in current baseline
Initial integration applied to reduce disruption:
- `RecipeListPage` page/section/button/input/chip/badge usage
- `RecipeForm` input/textarea/button usage
- `TagSelector` chip/input/button usage
- `RecipeCard` card/chip usage
## 6) Usage pattern
Prefer semantic baseline classes + utility classes for layout only.
Example:
```tsx
<button className="ui-btn ui-btn-primary px-5">Save Recipe</button>
<input className="ui-input" />
<span className="ui-chip">Vegetarian</span>
```
## 7) Notes for next tasks (T04+)
- Reuse these primitives before introducing one-off visual patterns.
- Keep all new colors mapped to semantic tokens.
- Preserve AA contrast and focus visibility for every interactive control.
## 8) Imagery and icon direction update (Photo-First)
- Food storytelling should use real photography as the default medium.
- Legacy custom illustrations are fallback/decorative only.
- Prefer photographic aspect ratios:
- list cards: 4:3
- detail hero: 16:9 (or 3:2)
- square thumbnails: 1:1
- Keep icons modern and restrained; standardize on `lucide-react` for action/navigation icons.
- Avoid emoji icons in production UI where a system icon exists.
See also: `docs/visual-audit/photo-first-assets.md` and `frontend/public/assets/photos/manifest.json`.

View File

@ -1,43 +0,0 @@
#!/usr/bin/env node
import initSqlJs from 'sql.js';
import fs from 'fs';
import path from 'path';
const DB_PATH = 'data/recipes.db';
async function fixImagePaths() {
const SQL = await initSqlJs();
const dbBuffer = fs.readFileSync(DB_PATH);
const db = new SQL.Database(dbBuffer);
// Get all recipes with image_url starting with 'images/'
const recipes = db.exec(`
SELECT id, title, image_url
FROM recipes
WHERE image_url LIKE 'images/%'
`);
if (recipes.length === 0 || recipes[0].values.length === 0) {
console.log('✓ No image paths to update');
return;
}
let updated = 0;
recipes[0].values.forEach(([id, title, imageUrl]) => {
// Change 'images/file.jpg' to '/images/file.jpg'
const newUrl = '/' + imageUrl;
db.run(`UPDATE recipes SET image_url = ? WHERE id = ?`, [newUrl, id]);
console.log(`✓ Updated recipe ${id} (${title}): ${imageUrl}${newUrl}`);
updated++;
});
// Save the database
const data = db.export();
fs.writeFileSync(DB_PATH, data);
db.close();
console.log(`\n✓ Fixed ${updated} image paths`);
}
fixImagePaths().catch(console.error);

View File

@ -26,10 +26,6 @@ npm run preview
# Lint code # Lint code
npm run lint npm run lint
# Style governance checks (token/primitive drift guardrails)
npm run style:guardrails
npm run style:check
``` ```
## Project Structure ## Project Structure
@ -56,21 +52,3 @@ src/
## Architecture ## Architecture
See `/ARCHITECTURE.md` for full system architecture and patterns. See `/ARCHITECTURE.md` for full system architecture and patterns.
## Visual Assets + Fallbacks
- Visual asset definitions live in `src/assets/visualAssets.ts`.
- The homepage hero uses a fallback chain: bundled `hero.png` first, then `/images/hero-fallback.svg`.
- Keep fallback assets lightweight (SVG preferred) and store browser-served fallbacks under `public/images/`.
- Any new UI-critical image should follow the same fallback pattern to avoid broken-image regressions in production.
## Styling Guardrails
This frontend uses a token + primitive styling contract.
- Governance doc: `../.harness/docs/styling-governance.md`
- Token contract: `../.harness/docs/styling-token-contract.md`
- Primitive contract: `../.harness/docs/ui-primitive-contract.md`
Use `npm run style:guardrails` before PRs touching app shell/shared UI surfaces.

View File

@ -7,9 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview"
"style:guardrails": "node ./scripts/style-guardrails.mjs",
"style:check": "npm run style:guardrails && npm run lint"
}, },
"dependencies": { "dependencies": {
"react": "^19.2.4", "react": "^19.2.4",

View File

@ -1,28 +0,0 @@
# Frontend Visual Assets
This folder contains visual assets for Recipe Manager.
## Directory map
- `photos/`**new photo-first asset surface** (manifest + future vendored photos)
- `hero/` — legacy illustration hero assets (deprecated for primary storytelling)
- `food/` — placeholders and legacy illustrated food assets
- `food/curated/` — legacy illustrated recipe art (deprecated for cards)
- `category/` — category icons (retain)
- `category/illustrations/` — legacy illustrated category art (deprecated)
- `empty-state/` — empty-state illustrations (retain temporarily)
- `ui/` — shared decorative UI assets
## Direction update
As of the photo-first correction wave, **real food photography is the primary visual direction**.
Illustrations are now fallback/support-only unless explicitly approved.
## Source and licensing
- Legacy SVG pack: original repository-owned artwork, no attribution required.
- Photo-first pipeline: see `photos/README.md` and `photos/manifest.json`.
Docs:
- `docs/visual-audit/assets.md`
- `docs/visual-audit/photo-first-assets.md`

View File

@ -1,6 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Breakfast icon">
<circle cx="64" cy="64" r="60" fill="#FEF3C7"/>
<circle cx="64" cy="64" r="28" fill="#F59E0B"/>
<circle cx="64" cy="64" r="16" fill="#FDE68A"/>
<path d="M24 96H104" stroke="#92400E" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 378 B

View File

@ -1,6 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dessert icon">
<circle cx="64" cy="64" r="56" fill="#FCE7F3"/>
<path d="M34 84H94L86 50H42L34 84Z" fill="#F472B6"/>
<circle cx="64" cy="44" r="12" fill="#FB7185"/>
<rect x="61" y="22" width="6" height="14" rx="3" fill="#BE123C"/>
</svg>

Before

Width:  |  Height:  |  Size: 367 B

View File

@ -1,5 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dinner icon">
<circle cx="64" cy="64" r="52" fill="#E0E7FF"/>
<circle cx="64" cy="64" r="30" stroke="#4F46E5" stroke-width="10"/>
<path d="M38 92C46 84 54 80 64 80C74 80 82 84 90 92" stroke="#4F46E5" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 377 B

View File

@ -1,5 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Lunch icon">
<rect x="14" y="20" width="100" height="88" rx="18" fill="#DCFCE7"/>
<rect x="36" y="34" width="56" height="60" rx="12" fill="#22C55E"/>
<path d="M64 12V26" stroke="#166534" stroke-width="8" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 364 B

View File

@ -1,7 +0,0 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Snack icon">
<circle cx="64" cy="64" r="56" fill="#FFEDD5"/>
<rect x="36" y="30" width="56" height="68" rx="16" fill="#FB923C"/>
<circle cx="64" cy="50" r="8" fill="#FDBA74"/>
<circle cx="50" cy="68" r="6" fill="#FDBA74"/>
<circle cx="78" cy="68" r="6" fill="#FDBA74"/>
</svg>

Before

Width:  |  Height:  |  Size: 409 B

View File

@ -1 +0,0 @@
<svg width="512" height="384" viewBox="0 0 512 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Breakfast category illustration</title><desc id="d">Original in-project breakfast illustration with sun and plate.</desc><rect width="512" height="384" fill="#FFF7ED"/><circle cx="408" cy="88" r="44" fill="#FCD34D"/><ellipse cx="256" cy="246" rx="148" ry="54" fill="#FFFFFF" stroke="#FDBA74" stroke-width="4"/><circle cx="256" cy="242" r="36" fill="#FBBF24"/></svg>

Before

Width:  |  Height:  |  Size: 499 B

View File

@ -1 +0,0 @@
<svg width="512" height="384" viewBox="0 0 512 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Dessert category illustration</title><desc id="d">Original in-project dessert illustration with cupcake silhouette.</desc><rect width="512" height="384" fill="#FDF2F8"/><path d="M180 252H332L314 306H198L180 252Z" fill="#F472B6"/><path d="M188 252C188 212 218 178 256 178C294 178 324 212 324 252H188Z" fill="#FB7185"/><circle cx="256" cy="168" r="12" fill="#EF4444"/></svg>

Before

Width:  |  Height:  |  Size: 506 B

View File

@ -1 +0,0 @@
<svg width="512" height="384" viewBox="0 0 512 384" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Dinner category illustration</title><desc id="d">Original in-project dinner illustration with cloche and stars.</desc><rect width="512" height="384" fill="#EEF2FF"/><circle cx="106" cy="90" r="6" fill="#C4B5FD"/><circle cx="146" cy="70" r="4" fill="#C4B5FD"/><circle cx="166" cy="106" r="5" fill="#C4B5FD"/><path d="M130 252C130 184 186 132 256 132C326 132 382 184 382 252H130Z" fill="#A5B4FC"/><rect x="112" y="252" width="288" height="28" rx="14" fill="#6366F1"/></svg>

Before

Width:  |  Height:  |  Size: 605 B

View File

@ -1 +0,0 @@
<svg width="800" height="600" viewBox="0 0 800 600" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">No cooking history illustration</title><desc id="d">Original in-project empty-state illustration for no recent cooking activity.</desc><rect width="800" height="600" fill="#F8FAFC"/><rect x="220" y="150" width="360" height="320" rx="24" fill="#FFFFFF" stroke="#CBD5E1" stroke-width="4"/><rect x="280" y="220" width="240" height="18" rx="9" fill="#E2E8F0"/><rect x="280" y="260" width="180" height="18" rx="9" fill="#E2E8F0"/><rect x="280" y="300" width="220" height="18" rx="9" fill="#E2E8F0"/><circle cx="400" cy="410" r="42" fill="#DBEAFE"/><path d="M400 392V418L418 428" stroke="#2563EB" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/></svg>

Before

Width:  |  Height:  |  Size: 796 B

View File

@ -1,6 +0,0 @@
<svg width="960" height="640" viewBox="0 0 960 640" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="No favorite recipes">
<rect width="960" height="640" rx="32" fill="#FFF1F2"/>
<path d="M480 418L309 247C267 205 267 137 309 95C351 53 419 53 461 95L480 114L499 95C541 53 609 53 651 95C693 137 693 205 651 247L480 418Z" fill="#FB7185"/>
<circle cx="480" cy="260" r="82" fill="#FECDD3"/>
<text x="480" y="530" text-anchor="middle" font-family="Inter, sans-serif" font-size="36" fill="#9F1239">No favorites yet</text>
</svg>

Before

Width:  |  Height:  |  Size: 551 B

View File

@ -1,12 +0,0 @@
<svg width="960" height="640" viewBox="0 0 960 640" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">No recipes found</title>
<desc id="desc">Illustration of a cookbook and magnifier to show empty search results.</desc>
<rect width="960" height="640" rx="32" fill="#F8FAFC"/>
<rect x="210" y="170" width="300" height="320" rx="24" fill="#E2E8F0"/>
<rect x="238" y="208" width="244" height="24" rx="12" fill="#94A3B8"/>
<rect x="238" y="256" width="188" height="18" rx="9" fill="#CBD5E1"/>
<rect x="238" y="288" width="224" height="18" rx="9" fill="#CBD5E1"/>
<circle cx="612" cy="320" r="88" stroke="#F97316" stroke-width="20"/>
<path d="M672 382L742 452" stroke="#F97316" stroke-width="20" stroke-linecap="round"/>
<text x="480" y="560" text-anchor="middle" font-family="Inter, sans-serif" font-size="38" fill="#334155">No recipes match your filters</text>
</svg>

Before

Width:  |  Height:  |  Size: 942 B

View File

@ -1,10 +0,0 @@
<svg width="960" height="640" viewBox="0 0 960 640" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Search returned no results">
<rect width="960" height="640" rx="32" fill="#EFF6FF"/>
<rect x="170" y="160" width="620" height="90" rx="45" fill="#DBEAFE"/>
<circle cx="250" cy="205" r="24" stroke="#3B82F6" stroke-width="10"/>
<path d="M266 221L287 242" stroke="#3B82F6" stroke-width="10" stroke-linecap="round"/>
<rect x="310" y="186" width="350" height="38" rx="19" fill="#BFDBFE"/>
<circle cx="480" cy="380" r="120" stroke="#93C5FD" stroke-width="16" stroke-dasharray="14 12"/>
<path d="M432 380L468 416L536 348" stroke="#1D4ED8" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<text x="480" y="560" text-anchor="middle" font-family="Inter, sans-serif" font-size="36" fill="#1E3A8A">Try a different keyword</text>
</svg>

Before

Width:  |  Height:  |  Size: 880 B

View File

@ -1 +0,0 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Salad bowl placeholder</title><desc id="d">Original in-project illustration representing a fresh salad bowl.</desc><rect width="1200" height="900" fill="#F0FDF4"/><ellipse cx="600" cy="590" rx="280" ry="120" fill="#DCFCE7"/><path d="M320 560C320 470 445 400 600 400C755 400 880 470 880 560C880 650 755 720 600 720C445 720 320 650 320 560Z" fill="#FFFFFF" stroke="#86EFAC" stroke-width="6"/><circle cx="510" cy="540" r="42" fill="#34D399"/><circle cx="585" cy="510" r="36" fill="#F59E0B"/><circle cx="655" cy="550" r="44" fill="#22C55E"/><circle cx="730" cy="515" r="34" fill="#F97316"/><circle cx="600" cy="575" r="40" fill="#10B981"/></svg>

Before

Width:  |  Height:  |  Size: 777 B

View File

@ -1 +0,0 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Breakfast toast placeholder</title><desc id="d">Original in-project illustration of toast and egg breakfast.</desc><rect width="1200" height="900" fill="#EFF6FF"/><rect x="360" y="380" width="480" height="300" rx="40" fill="#FFFFFF" stroke="#BFDBFE" stroke-width="6"/><rect x="440" y="450" width="180" height="170" rx="26" fill="#D97706"/><rect x="460" y="470" width="140" height="130" rx="20" fill="#FCD34D"/><ellipse cx="700" cy="530" rx="90" ry="70" fill="#FFFFFF" stroke="#E5E7EB" stroke-width="4"/><circle cx="700" cy="530" r="30" fill="#FBBF24"/><circle cx="570" cy="420" r="18" fill="#F59E0B"/><circle cx="620" cy="410" r="14" fill="#F59E0B"/></svg>

Before

Width:  |  Height:  |  Size: 792 B

View File

@ -1 +0,0 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Dessert cake placeholder</title><desc id="d">Original in-project illustration of cake slice and berry garnish.</desc><rect width="1200" height="900" fill="#FDF2F8"/><ellipse cx="600" cy="620" rx="320" ry="120" fill="#FBCFE8"/><path d="M430 630L570 420H780L640 630Z" fill="#F9A8D4" stroke="#EC4899" stroke-width="6"/><path d="M570 420H780L740 490H530Z" fill="#FDE68A"/><circle cx="705" cy="500" r="18" fill="#EF4444"/><circle cx="665" cy="515" r="16" fill="#EF4444"/><rect x="430" y="630" width="210" height="24" rx="12" fill="#EC4899"/></svg>

Before

Width:  |  Height:  |  Size: 678 B

View File

@ -1 +0,0 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Pasta plate placeholder</title><desc id="d">Original in-project illustration representing pasta dish.</desc><rect width="1200" height="900" fill="#FFF7ED"/><ellipse cx="600" cy="560" rx="320" ry="140" fill="#FED7AA"/><ellipse cx="600" cy="550" rx="280" ry="110" fill="#FFFFFF" stroke="#FDBA74" stroke-width="6"/><path d="M430 530C520 470 680 470 770 530" stroke="#F59E0B" stroke-width="20" stroke-linecap="round"/><path d="M420 565C515 505 685 505 780 565" stroke="#FBBF24" stroke-width="20" stroke-linecap="round"/><path d="M450 600C535 550 665 550 750 600" stroke="#F59E0B" stroke-width="20" stroke-linecap="round"/><circle cx="500" cy="490" r="16" fill="#EF4444"/><circle cx="700" cy="500" r="14" fill="#EF4444"/><circle cx="620" cy="615" r="14" fill="#16A34A"/></svg>

Before

Width:  |  Height:  |  Size: 907 B

View File

@ -1 +0,0 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Smoothie glass placeholder</title><desc id="d">Original in-project illustration of fruit smoothie drink.</desc><rect width="1200" height="900" fill="#ECFEFF"/><rect x="500" y="280" width="200" height="430" rx="28" fill="#FFFFFF" stroke="#67E8F9" stroke-width="6"/><rect x="520" y="370" width="160" height="300" rx="20" fill="#22D3EE"/><path d="M600 280V180" stroke="#0891B2" stroke-width="10" stroke-linecap="round"/><path d="M600 180L670 120" stroke="#0891B2" stroke-width="10" stroke-linecap="round"/><circle cx="560" cy="340" r="22" fill="#F87171"/><circle cx="640" cy="330" r="20" fill="#A3E635"/><circle cx="700" cy="680" r="26" fill="#06B6D4" opacity="0.4"/><circle cx="480" cy="690" r="24" fill="#06B6D4" opacity="0.3"/></svg>

Before

Width:  |  Height:  |  Size: 869 B

View File

@ -1 +0,0 @@
<svg width="1200" height="900" viewBox="0 0 1200 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="t d"><title id="t">Soup bowl placeholder</title><desc id="d">Original in-project illustration of warm soup bowl.</desc><rect width="1200" height="900" fill="#FFF1F2"/><ellipse cx="600" cy="610" rx="300" ry="110" fill="#FECDD3"/><path d="M360 560C360 470 467 400 600 400C733 400 840 470 840 560C840 650 733 720 600 720C467 720 360 650 360 560Z" fill="#FFFFFF" stroke="#FDA4AF" stroke-width="6"/><ellipse cx="600" cy="560" rx="210" ry="80" fill="#FB923C"/><path d="M500 470C485 430 510 395 545 380" stroke="#FCA5A5" stroke-width="12" stroke-linecap="round"/><path d="M600 470C585 430 610 395 645 380" stroke="#FCA5A5" stroke-width="12" stroke-linecap="round"/><path d="M700 470C685 430 710 395 745 380" stroke="#FCA5A5" stroke-width="12" stroke-linecap="round"/></svg>

Before

Width:  |  Height:  |  Size: 883 B

View File

@ -1,11 +0,0 @@
<svg width="900" height="900" viewBox="0 0 900 900" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Recipe placeholder square">
<rect width="900" height="900" rx="40" fill="#F8FAFC"/>
<rect x="90" y="90" width="720" height="720" rx="32" fill="#ECFEFF"/>
<ellipse cx="450" cy="460" rx="220" ry="126" fill="#E2E8F0"/>
<ellipse cx="450" cy="445" rx="200" ry="110" fill="#FFF"/>
<circle cx="366" cy="430" r="30" fill="#F97316"/>
<circle cx="425" cy="398" r="26" fill="#22C55E"/>
<circle cx="485" cy="442" r="24" fill="#FB7185"/>
<circle cx="546" cy="410" r="20" fill="#EAB308"/>
<text x="450" y="650" text-anchor="middle" font-family="Inter, sans-serif" font-size="40" fill="#334155">Recipe Image</text>
</svg>

Before

Width:  |  Height:  |  Size: 747 B

View File

@ -1,10 +0,0 @@
<svg width="800" height="600" viewBox="0 0 800 600" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Recipe placeholder 4 by 3">
<rect width="800" height="600" rx="28" fill="#FFF7ED"/>
<rect x="70" y="70" width="660" height="460" rx="24" fill="#FFEDD5"/>
<ellipse cx="400" cy="330" rx="170" ry="98" fill="#F8FAFC"/>
<circle cx="330" cy="300" r="24" fill="#F97316"/>
<circle cx="390" cy="282" r="22" fill="#22C55E"/>
<circle cx="446" cy="308" r="20" fill="#EAB308"/>
<circle cx="500" cy="287" r="18" fill="#FB7185"/>
<text x="400" y="470" text-anchor="middle" font-family="Inter, sans-serif" font-size="28" fill="#475569">No photo yet</text>
</svg>

Before

Width:  |  Height:  |  Size: 685 B

View File

@ -1,21 +0,0 @@
<svg width="1200" height="800" viewBox="0 0 1200 800" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Recipe image placeholder</title>
<desc id="desc">Abstract food bowl illustration used as a safe placeholder image.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="800" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF7ED"/>
<stop offset="1" stop-color="#FEF3C7"/>
</linearGradient>
</defs>
<rect width="1200" height="800" rx="36" fill="url(#bg)"/>
<ellipse cx="600" cy="470" rx="280" ry="150" fill="#E2E8F0"/>
<ellipse cx="600" cy="450" rx="250" ry="125" fill="#F8FAFC"/>
<circle cx="500" cy="430" r="28" fill="#F97316"/>
<circle cx="560" cy="405" r="34" fill="#84CC16"/>
<circle cx="630" cy="445" r="30" fill="#22C55E"/>
<circle cx="690" cy="410" r="26" fill="#FB7185"/>
<circle cx="740" cy="445" r="22" fill="#F59E0B"/>
<rect x="410" y="560" width="380" height="20" rx="10" fill="#CBD5E1"/>
<text x="600" y="662" text-anchor="middle" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="46" fill="#475569">Recipe Photo Placeholder</text>
<text x="600" y="712" text-anchor="middle" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-size="28" fill="#64748B">Generated in-project, copyright-safe</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,8 +0,0 @@
<svg width="960" height="540" viewBox="0 0 960 540" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Upload photo placeholder">
<rect width="960" height="540" rx="24" fill="#F8FAFC"/>
<rect x="40" y="40" width="880" height="460" rx="20" fill="#EEF2FF" stroke="#94A3B8" stroke-width="4" stroke-dasharray="10 10"/>
<path d="M480 198V302" stroke="#475569" stroke-width="16" stroke-linecap="round"/>
<path d="M428 250L480 198L532 250" stroke="#475569" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<text x="480" y="360" text-anchor="middle" font-family="Inter, sans-serif" font-size="36" fill="#334155">Drop image or click to upload</text>
<text x="480" y="408" text-anchor="middle" font-family="Inter, sans-serif" font-size="24" fill="#64748B">JPG, PNG, WebP — placeholder graphic</text>
</svg>

Before

Width:  |  Height:  |  Size: 848 B

View File

@ -1,25 +0,0 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Fresh produce hero illustration</title>
<desc id="desc">Original abstract produce composition with leafy accents and platter for recipe app hero usage.</desc>
<defs>
<linearGradient id="bg2" x1="0" y1="0" x2="1600" y2="900" gradientUnits="userSpaceOnUse">
<stop stop-color="#ECFDF3"/>
<stop offset="1" stop-color="#DCFCE7"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg2)"/>
<ellipse cx="800" cy="640" rx="500" ry="150" fill="#BBF7D0"/>
<ellipse cx="800" cy="630" rx="420" ry="120" fill="#FFFFFF" stroke="#A7F3D0" stroke-width="6"/>
<path d="M300 370C370 300 430 300 500 370" stroke="#16A34A" stroke-width="28" stroke-linecap="round"/>
<path d="M1100 370C1170 300 1230 300 1300 370" stroke="#16A34A" stroke-width="28" stroke-linecap="round"/>
<circle cx="640" cy="590" r="56" fill="#F97316"/>
<circle cx="740" cy="565" r="46" fill="#FB7185"/>
<circle cx="840" cy="598" r="52" fill="#FACC15"/>
<circle cx="945" cy="560" r="48" fill="#A3E635"/>
<circle cx="1040" cy="592" r="50" fill="#34D399"/>
<ellipse cx="640" cy="515" rx="26" ry="10" fill="#22C55E"/>
<ellipse cx="740" cy="498" rx="22" ry="9" fill="#22C55E"/>
<ellipse cx="840" cy="520" rx="24" ry="10" fill="#22C55E"/>
<ellipse cx="945" cy="493" rx="22" ry="9" fill="#22C55E"/>
<ellipse cx="1040" cy="523" rx="24" ry="10" fill="#22C55E"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,28 +0,0 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Kitchen prep hero illustration</title>
<desc id="desc">Original abstract kitchen-themed hero illustration with produce, bowl and utensils.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1600" y2="900" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF8F1"/>
<stop offset="1" stop-color="#FEEDE2"/>
</linearGradient>
<linearGradient id="plate" x1="520" y1="340" x2="1040" y2="700" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#F5F7FF"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg)"/>
<circle cx="1230" cy="210" r="120" fill="#FFE4B8" opacity="0.7"/>
<circle cx="240" cy="180" r="90" fill="#DDF4FF" opacity="0.7"/>
<rect x="160" y="560" width="1280" height="190" rx="24" fill="#F0D7C1"/>
<ellipse cx="800" cy="560" rx="360" ry="120" fill="url(#plate)" stroke="#E5E7EB" stroke-width="6"/>
<ellipse cx="800" cy="560" rx="250" ry="76" fill="#F7FAFC"/>
<circle cx="700" cy="530" r="38" fill="#FF6B6B"/>
<circle cx="760" cy="576" r="32" fill="#4CAF50"/>
<circle cx="855" cy="520" r="34" fill="#F59E0B"/>
<circle cx="910" cy="582" r="30" fill="#22C55E"/>
<rect x="470" y="410" width="58" height="180" rx="28" fill="#8B5E3C"/>
<rect x="1072" y="408" width="58" height="180" rx="28" fill="#8B5E3C"/>
<path d="M499 390C499 354 526 328 560 328C594 328 621 354 621 390V420H499V390Z" fill="#B08968"/>
<path d="M1099 390C1099 354 1126 328 1160 328C1194 328 1221 354 1221 390V420H1099V390Z" fill="#B08968"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,34 +0,0 @@
# Photo-First Assets (Wave 2.5)
This directory is the handoff surface for migrating from illustration-first to photo-first art direction.
## Current status
- `manifest.json` is the source of truth for photo slots and candidate sources.
- No third-party photos are vendored yet in this repo.
- Existing SVG placeholders remain as safe local fallbacks until approved photos are added.
## Folder intent
- `hero/` — homepage/detail hero photos (`.jpg`/`.webp`)
- `list/` — 4:3 and 1:1 card/list photos
- `detail/` — 16:9 or 3:2 detail hero photos
- `category/` — optional category cover photos
- `empty-state/` — optional minimal photographic empty-state support
- `credits/` — attribution and source snapshots when required
## Licensing guardrails
Preferred source order:
1. **Pexels** (free commercial use, attribution typically not required)
2. **Unsplash** (free use under Unsplash License, attribution appreciated)
3. **Owned/internal photos** (full control)
Rules:
- Verify license at time of download and record it in `manifest.json`.
- Never ingest images marked editorial-only or with recognizable private persons without release confidence.
- Keep `sourceUrl`, `license`, and `downloadedAt` metadata for every vendored photo.
## Integration
UI workers should consume `manifest.json` and gracefully fallback to local placeholders when `localPath` is null.

View File

@ -1,76 +0,0 @@
{
"version": 1,
"updatedAt": "2026-03-27",
"notes": "Photo-first manifest for Wave 2.5 UI integration. Do not assume listed external photos are vendored until localPath exists.",
"usage": {
"homepageHero": "assets.photos.hero.homepage.primary",
"listCardFallback": "assets.photos.list.default4x3",
"detailHeroFallback": "assets.photos.detail.default16x9",
"emptyStates": "assets.photos.emptyState"
},
"assets": {
"photos": {
"hero": {
"homepage": {
"primary": {
"id": "hero-market-produce-01",
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/1640774/pexels-photo-1640774.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
},
"fallback": [
"/assets/food/placeholder-recipe.svg"
]
}
},
"list": {
"default4x3": "/assets/food/placeholder-recipe-4x3.svg",
"default1x1": "/assets/food/placeholder-recipe-1x1.svg"
},
"detail": {
"default16x9": "/assets/food/placeholder-recipe.svg"
},
"category": {
"breakfast": {
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/70497/pexels-photo-70497.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
},
"dinner": {
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/1279330/pexels-photo-1279330.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
},
"dessert": {
"localPath": null,
"remoteCandidate": "https://images.pexels.com/photos/291528/pexels-photo-291528.jpeg",
"license": "Pexels License",
"attributionRequired": false,
"status": "candidate-not-vendored"
}
},
"emptyState": {
"noRecipes": "/assets/empty-state/no-recipes.svg",
"noResults": "/assets/empty-state/no-results-search.svg",
"noFavorites": "/assets/empty-state/no-favorites.svg"
}
},
"icons": {
"recommendedLibrary": "lucide-react",
"size": {
"control": 18,
"section": 24
},
"strokeWidth": {
"default": 1.75
},
"policy": "Use icons for navigation/actions only. Do not use illustrated iconography for food storytelling."
}
}
}

View File

@ -1,7 +0,0 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Soft decorative background pattern">
<rect width="1600" height="900" fill="#F8FAFC"/>
<circle cx="180" cy="120" r="180" fill="#DBEAFE"/>
<circle cx="1440" cy="180" r="220" fill="#FFEDD5"/>
<circle cx="320" cy="780" r="240" fill="#DCFCE7"/>
<circle cx="1320" cy="760" r="200" fill="#FCE7F3"/>
</svg>

Before

Width:  |  Height:  |  Size: 433 B

View File

@ -1,26 +0,0 @@
<svg width="1600" height="900" viewBox="0 0 1600 900" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
<title id="title">Recipe hero fallback illustration</title>
<desc id="desc">Soft gradient with simple food-themed shapes used when the hero image is unavailable.</desc>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#fde68a"/>
<stop offset="55%" stop-color="#fed7aa"/>
<stop offset="100%" stop-color="#bfdbfe"/>
</linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#bg)"/>
<g opacity="0.15" fill="#1e293b">
<circle cx="250" cy="180" r="120"/>
<circle cx="1430" cy="710" r="180"/>
<circle cx="1210" cy="180" r="90"/>
</g>
<g fill="#0f172a" opacity="0.65" font-family="Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif" text-anchor="middle">
<text x="800" y="420" font-size="84" font-weight="700">Recipe Manager</text>
<text x="800" y="500" font-size="34" font-weight="500">Fresh meals. Organized beautifully.</text>
</g>
<g font-size="84" text-anchor="middle">
<text x="620" y="640">🥗</text>
<text x="800" y="640">🍲</text>
<text x="980" y="640">🍰</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,76 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
const projectRoot = process.cwd();
const scopedFiles = [
'src/App.tsx',
'src/components/MissionControlPanel.tsx',
'src/components/ui/primitives.tsx',
'src/components/ErrorBoundary.tsx',
'src/pages/NotFoundPage.tsx',
'src/pages/RecipeListPage.tsx',
'src/components/RecipeCard.tsx',
'src/components/Toast.tsx',
].map((file) => path.resolve(projectRoot, file));
const bannedPatterns = [
{
name: 'raw-tailwind-palette',
regex:
/\b(?:bg|text|border|ring|from|to|via|outline|decoration)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}\b/g,
message:
'Use tokenized classes/variables instead of direct Tailwind palette utilities (e.g. bg-slate-200, text-blue-600).',
},
{
name: 'hex-in-arbitrary-class',
regex: /\b(?:bg|text|border|ring|from|to|via)-\[#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\]\b/g,
message:
'Avoid hardcoded hex colors in className. Add/consume a semantic token in tokens.css instead.',
},
];
function readFile(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function lineNumberAt(text, index) {
return text.slice(0, index).split('\n').length;
}
const findings = [];
for (const filePath of scopedFiles) {
if (!fs.existsSync(filePath)) continue;
const content = readFile(filePath);
for (const rule of bannedPatterns) {
for (const match of content.matchAll(rule.regex)) {
findings.push({
filePath,
line: lineNumberAt(content, match.index ?? 0),
value: match[0],
rule: rule.name,
message: rule.message,
});
}
}
}
if (findings.length === 0) {
console.log('✅ style-guardrails: no blocked raw palette patterns found in scoped files.');
process.exit(0);
}
console.error('❌ style-guardrails: found styling drift patterns in guarded files:\n');
for (const finding of findings) {
const relativePath = path.relative(projectRoot, finding.filePath);
console.error(`- ${relativePath}:${finding.line} [${finding.rule}] ${finding.value}`);
console.error(` ${finding.message}`);
}
console.error('\nSee .harness/docs/styling-governance.md for the contract and escalation path.');
process.exit(1);

184
frontend/src/App.css Normal file
View File

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

View File

@ -8,9 +8,9 @@ import { ErrorBoundary } from './components/ErrorBoundary';
import { ToastContainer } from './components/Toast'; import { ToastContainer } from './components/Toast';
import { useToast } from './hooks/useToast'; import { useToast } from './hooks/useToast';
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
import { UiPage } from './components/ui/primitives'; import { colors, radius } from './theme';
import { cn } from './components/ui/cn';
// Create toast context to share toast functionality across the app
interface ToastContextType { interface ToastContextType {
success: (message: string, duration?: number) => string; success: (message: string, duration?: number) => string;
error: (message: string, duration?: number) => string; error: (message: string, duration?: number) => string;
@ -20,33 +20,6 @@ interface ToastContextType {
const ToastContext = createContext<ToastContextType | null>(null); const ToastContext = createContext<ToastContextType | null>(null);
function BookIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="h-4 w-4" aria-hidden="true">
<path d="M5 5.5A2.5 2.5 0 0 1 7.5 3H19v16H7.5A2.5 2.5 0 0 0 5 21V5.5Z" />
<path d="M5 21h14" />
</svg>
);
}
function PlusIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4" aria-hidden="true">
<path d="M12 5v14" />
<path d="M5 12h14" />
</svg>
);
}
function LinkIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="h-4 w-4" aria-hidden="true">
<path d="M10 13a5 5 0 0 0 7.07 0l2.12-2.12a5 5 0 0 0-7.07-7.07L10.7 5.23" />
<path d="M14 11a5 5 0 0 0-7.07 0L4.8 13.12a5 5 0 0 0 7.07 7.07l1.42-1.42" />
</svg>
);
}
export function useToastContext() { export function useToastContext() {
const context = useContext(ToastContext); const context = useContext(ToastContext);
if (!context) { if (!context) {
@ -65,51 +38,43 @@ function App() {
return false; return false;
}; };
const linkClass = (path: string) => const linkClass = (path: string) => {
cn( const base = `px-4 py-2 rounded-full text-sm font-semibold transition-colors shadow-sm`;
'ui-btn min-h-0 rounded-full border border-transparent px-4 py-2 text-sm font-semibold shadow-none', return isActive(path)
isActive(path) ? `${base} bg-blue-100 text-blue-700`
? 'bg-[var(--color-primary-light)] text-[var(--color-primary-dark)]' : `${base} text-gray-700 hover:bg-gray-100`;
: 'bg-transparent text-[var(--text-dim)] hover:bg-[var(--surface-muted)] hover:text-[var(--text)]', };
);
return ( return (
<ErrorBoundary> <ErrorBoundary>
<ToastContext.Provider value={toast}> <ToastContext.Provider value={toast}>
<div className="min-h-screen text-[var(--text)]"> <div className="min-h-screen bg-gray-50">
<ToastContainer messages={toast.messages} onClose={toast.removeToast} /> <ToastContainer messages={toast.messages} onClose={toast.removeToast} />
<header className="sticky top-0 z-20 border-b border-[var(--border)]/70 bg-[var(--surface)]/85 shadow-sm backdrop-blur-md"> <header className="bg-white shadow-sm border-b border-gray-100 ">
<UiPage className="px-4 py-0"> <div className="max-w-7xl mx-auto px-4">
<div className="flex h-16 items-center justify-between gap-3"> <div className="flex items-center justify-between h-16">
<div className="flex items-center"> <div className="flex items-center">
<Link <Link to="/" className="flex-shrink-0">
to="/" <h1 className="text-2xl font-bold text-gray-900 tracking-tight">Recipe Manager</h1>
className="group inline-flex items-center gap-2 rounded-lg px-2 py-1.5 outline-none transition-colors duration-200 hover:text-[var(--color-primary-dark)]"
>
<span className="text-xl" aria-hidden="true">🍽</span>
<h1 className="text-xl font-bold tracking-tight text-[var(--text-h)] sm:text-2xl">Recipe Manager</h1>
</Link> </Link>
</div> </div>
<nav aria-label="Primary" className="flex flex-wrap items-center justify-end gap-2 sm:gap-3"> <nav className="flex space-x-3">
<Link to="/" className={linkClass('/')}> <Link to="/" className={linkClass('/')}>
<BookIcon /> Recipes
<span>Recipes</span>
</Link> </Link>
<Link to="/recipe/new" className={linkClass('/recipe/new')}> <Link to="/recipe/new" className={linkClass('/recipe/new')}>
<PlusIcon /> Add Recipe
<span>Add Recipe</span>
</Link> </Link>
<Link to="/import/url" className={linkClass('/import/url')}> <Link to="/import/url" className={linkClass('/import/url')}>
<LinkIcon /> Import URL
<span>Import URL</span>
</Link> </Link>
</nav> </nav>
</div> </div>
</UiPage> </div>
</header> </header>
<main className="ui-page min-h-[70vh] px-4 py-8"> <main className="max-w-7xl mx-auto py-8 px-4 min-h-[70vh]">
<Routes> <Routes>
<Route path="/" element={<RecipeListPage />} /> <Route path="/" element={<RecipeListPage />} />
<Route path="/recipe/new" element={<RecipeDetailPage />} /> <Route path="/recipe/new" element={<RecipeDetailPage />} />
@ -120,25 +85,12 @@ function App() {
</Routes> </Routes>
</main> </main>
<footer className="mt-10 border-t border-[var(--border)]/80 bg-gradient-to-br from-[var(--surface)]/95 to-[var(--surface-muted)]/90 backdrop-blur-sm"> <footer className="bg-white border-t border-gray-100 mt-12">
<UiPage className="grid grid-cols-1 gap-6 px-4 py-8 sm:grid-cols-2 lg:grid-cols-3"> <div className="max-w-7xl mx-auto py-6 px-4">
<div> <p className="text-center text-sm text-gray-500">
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--color-primary-dark)]">Recipe Manager</p> Recipe Manager MVP - Built with React + Vite + TypeScript
<p className="mt-2 text-sm text-[var(--text-dim)]">Save recipes, organize by tags, and keep your kitchen workflow simple.</p> </p>
</div> </div>
<div>
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--text)]">Quick Links</p>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-[var(--text-dim)]">
<Link to="/" className="ui-chip bg-[var(--surface)] px-3 py-1 hover:bg-[var(--surface-muted)]">Browse</Link>
<Link to="/recipe/new" className="ui-chip bg-[var(--surface)] px-3 py-1 hover:bg-[var(--surface-muted)]">Add Recipe</Link>
<Link to="/import/url" className="ui-chip bg-[var(--surface)] px-3 py-1 hover:bg-[var(--surface-muted)]">Import URL</Link>
</div>
</div>
<div className="sm:col-span-2 lg:col-span-1">
<p className="text-sm font-semibold uppercase tracking-wide text-[var(--text)]">Built for everyday cooking</p>
<p className="mt-2 text-sm text-[var(--text-dim)]">React + Vite + TypeScript · Visual redesign in progress</p>
</div>
</UiPage>
</footer> </footer>
</div> </div>
</ToastContext.Provider> </ToastContext.Provider>

View File

@ -1,42 +0,0 @@
export const PHOTO_MANIFEST_PATH = '/assets/photos/manifest.json';
export type HomepageHeroPhotoSlot = {
localPath?: string | null;
remoteCandidate?: string | null;
fallback?: string[];
};
export type PhotoManifest = {
assets?: {
photos?: {
hero?: {
homepage?: {
primary?: HomepageHeroPhotoSlot;
};
};
};
};
};
/**
* Wave 2.5 helper:
* Fetch manifest and resolve semantic photo slots with local placeholder fallback.
*/
export async function loadPhotoManifest<T = unknown>(): Promise<T> {
const response = await fetch(PHOTO_MANIFEST_PATH);
if (!response.ok) {
throw new Error(`Failed to load photo manifest: ${response.status}`);
}
return (await response.json()) as T;
}
export function resolveHomepageHeroCandidates(
manifest: PhotoManifest,
defaultFallbacks: string[] = ['/assets/food/placeholder-recipe.svg']
): string[] {
const slot = manifest.assets?.photos?.hero?.homepage?.primary;
const resolved = [slot?.localPath, slot?.remoteCandidate, ...(slot?.fallback ?? []), ...defaultFallbacks]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0);
return Array.from(new Set(resolved));
}

View File

@ -1,21 +0,0 @@
import legacyHeroImage from './hero.png';
export const visualAssets = {
hero: {
/**
* Use T03 public hero assets first so runtime matches the new visual pack.
* Keep legacy/src fallback as a last resort to avoid broken hero rendering.
*/
primary: '/assets/hero/hero-kitchen-light.svg',
fallbacks: [
'/assets/hero/hero-fresh-produce.svg',
legacyHeroImage,
'/assets/food/placeholder-recipe.svg',
],
alt: 'Fresh ingredients and plated food',
},
} as const;
export function heroImageChain(): string[] {
return [visualAssets.hero.primary, ...visualAssets.hero.fallbacks];
}

View File

@ -3,7 +3,6 @@
*/ */
import { Component, type ReactNode } from 'react'; import { Component, type ReactNode } from 'react';
import { UiButton, UiCard, UiPage } from './ui/primitives';
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode; children: ReactNode;
@ -52,43 +51,42 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
} }
return ( return (
<UiPage className="flex min-h-screen items-center justify-center px-4"> <div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<UiCard className="w-full max-w-md p-6"> <div className="max-w-md w-full bg-white rounded-lg shadow-lg p-6">
<div className="mb-4 text-center"> <div className="text-center mb-4">
<div className="mb-4 text-6xl"></div> <div className="text-6xl mb-4"></div>
<h2 className="mb-2 text-2xl font-bold text-[var(--text-h)]">Something went wrong</h2> <h2 className="text-2xl font-bold text-gray-900 mb-2">Something went wrong</h2>
<p className="mb-4 text-[var(--text-dim)]"> <p className="text-gray-600 mb-4">
An unexpected error occurred. Please try refreshing the page. An unexpected error occurred. Please try refreshing the page.
</p> </p>
</div> </div>
<details className="mb-4"> <details className="mb-4">
<summary className="cursor-pointer text-sm font-medium text-[var(--text-dim)] transition-colors hover:text-[var(--text)]"> <summary className="cursor-pointer text-sm text-gray-600 hover:text-gray-800 font-medium">
Error details Error details
</summary> </summary>
<pre className="mt-2 overflow-auto rounded bg-[var(--surface-muted)] p-3 text-xs text-[var(--color-error)]"> <pre className="mt-2 p-3 bg-gray-50 rounded text-xs text-red-600 overflow-auto">
{this.state.error.toString()} {this.state.error.toString()}
{this.state.error.stack && `\n\n${this.state.error.stack}`} {this.state.error.stack && `\n\n${this.state.error.stack}`}
</pre> </pre>
</details> </details>
<div className="flex gap-3"> <div className="flex gap-3">
<UiButton <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
variant="primary" className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 font-medium"
className="flex-1"
> >
Refresh Page Refresh Page
</UiButton> </button>
<UiButton <button
onClick={this.resetError} onClick={this.resetError}
className="flex-1" className="flex-1 bg-gray-200 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-300 font-medium"
> >
Try Again Try Again
</UiButton> </button>
</div> </div>
</UiCard> </div>
</UiPage> </div>
); );
} }

View File

@ -1,35 +1,37 @@
import type { HarnessStatus } from '../types/recipe'; import type { HarnessStatus } from '../types/recipe';
import { UiSection, UiChip } from './ui/primitives';
function getRecentCommit(status: HarnessStatus) { function getRecentCommit(status: HarnessStatus) {
return status.commit?.relative || status.commit?.hash || ''; return status.commit?.relative || status.commit?.hash || '';
} }
export function MissionControlPanel({ status }: { status: HarnessStatus }) { export function MissionControlPanel({ status }: { status: HarnessStatus }) {
// Defensive for possibly undefined fields
const keepalive = status.keepalive || {}; const keepalive = status.keepalive || {};
const todo = status.todo || { checked: 0, unchecked: 0, nextTask: undefined }; const todo = status.todo || { checked: 0, unchecked: 0, nextTask: undefined };
const heartbeat = status.workerHeartbeatHistory || []; const heartbeat = status.workerHeartbeatHistory || [];
return ( return (
<UiSection className="mt-4 flex flex-col gap-3 border-[var(--border)]/70 bg-[var(--surface-muted)]/35 p-4" padding="none"> <div className="bg-gray-50 border-b p-4 flex flex-col gap-2">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex justify-between items-center">
<div> <div>
<div className="text-lg font-semibold text-[var(--text-h)]">Mission Control</div> <div className="font-semibold text-lg text-gray-700">Mission Control</div>
<div className="text-xs text-[var(--text-dim)]">Version: {status.version}</div> <div className="text-xs text-gray-500">Version: {status.version}</div>
</div>
<div className="flex gap-4">
<span className="text-xs text-gray-700">Git: {getRecentCommit(status)}</span>
</div> </div>
<UiChip className="bg-[var(--surface)] text-xs text-[var(--text)]">Git: {getRecentCommit(status) || 'n/a'}</UiChip>
</div> </div>
<div className="flex flex-wrap gap-4 mt-2">
<div className="flex flex-wrap gap-2"> <div className="text-xs">Keepalive: {keepalive.status || 'n/a'} ({keepalive.activeSessionLabel || 'none'})</div>
<UiChip className="text-xs">Keepalive: {keepalive.status || 'n/a'} ({keepalive.activeSessionLabel || 'none'})</UiChip> <div className="text-xs">Heartbeat: {keepalive.heartbeatAgeSeconds != null ? `${keepalive.heartbeatAgeSeconds}s ago` : 'n/a'}</div>
<UiChip className="text-xs">Heartbeat: {keepalive.heartbeatAgeSeconds != null ? `${keepalive.heartbeatAgeSeconds}s ago` : 'n/a'}</UiChip> <div className="text-xs">Todo: checked {todo.checked ?? 0}/unchecked {todo.unchecked ?? 0}</div>
<UiChip className="text-xs">Todo: checked {todo.checked ?? 0}/unchecked {todo.unchecked ?? 0}</UiChip> <div className="text-xs">Next: {todo.nextTask || 'n/a'}</div>
<UiChip className="text-xs">Next: {todo.nextTask || 'n/a'}</UiChip>
</div> </div>
{!!heartbeat.length && ( {!!heartbeat.length && (
<div className="text-xs text-[var(--text-dim)]">Worker events: {heartbeat.length} ({heartbeat[0]?.timestamp})</div> <div className="text-xs mt-2">
Worker events: {heartbeat.length} ({heartbeat[0]?.timestamp})
</div>
)} )}
</UiSection> </div>
); );
} }

View File

@ -1,32 +1,12 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { Recipe, Tag } from '../types/recipe'; import type { Recipe, Tag } from '../types/recipe';
import { colors, radius, shadows, typography } from '../theme'; import { colors, radius, shadows } from '../theme';
interface PhotoManifestCategoryAsset {
localPath?: string | null;
remoteCandidate?: string | null;
}
interface PhotoManifest {
assets?: {
photos?: {
list?: {
default4x3?: string;
};
category?: Record<string, PhotoManifestCategoryAsset>;
};
};
}
interface RecipeCardProps { interface RecipeCardProps {
recipe: Recipe; recipe: Recipe;
tags?: Tag[]; tags?: Tag[];
viewMode?: 'grid' | 'list';
photoManifest?: PhotoManifest | null;
} }
const DEFAULT_LIST_FALLBACK = '/assets/food/placeholder-recipe-4x3.svg';
function formatTime(minutes?: number): string { function formatTime(minutes?: number): string {
if (!minutes) return ''; if (!minutes) return '';
if (minutes < 60) return `${minutes}m`; if (minutes < 60) return `${minutes}m`;
@ -35,285 +15,67 @@ function formatTime(minutes?: number): string {
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
} }
function formatDate(timestamp?: number | null): string { function formatDate(timestamp?: number): string {
if (!timestamp) return ''; if (!timestamp) return '';
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
return date.toLocaleDateString(); return date.toLocaleDateString();
} }
function inferDifficulty(recipe: Recipe): 'Easy' | 'Medium' | 'Advanced' { export function RecipeCard({ recipe, tags = [] }: RecipeCardProps) {
const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0); const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0);
const ingredientCount = recipe.ingredients.length;
const stepCount = recipe.steps.length;
const complexityScore = totalTime / 20 + ingredientCount / 5 + stepCount / 4;
if (complexityScore < 4) return 'Easy';
if (complexityScore < 8) return 'Medium';
return 'Advanced';
}
function categoryForRecipe(tags: Tag[]): string {
return tags[0]?.name ?? 'Uncategorized';
}
function getRecipeImageUrl(recipe: Recipe): string | null {
const candidateKeys = [
'image_url',
'imageUrl',
'photo_url',
'photoUrl',
'thumbnail_url',
'thumbnailUrl',
'image',
'photo',
] as const;
const recipeRecord = recipe as unknown as Record<string, unknown>;
for (const key of candidateKeys) {
const value = recipeRecord[key];
if (typeof value !== 'string') continue;
const trimmed = value.trim();
if (!trimmed) continue;
if (
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed.startsWith('/') ||
trimmed.startsWith('data:image/') ||
trimmed.startsWith('blob:')
) {
return trimmed;
}
}
return null;
}
function recipeCategoryKey(recipe: Recipe, tags: Tag[]): 'breakfast' | 'dinner' | 'dessert' | null {
const haystack = `${recipe.title} ${tags.map((tag) => tag.name).join(' ')}`.toLowerCase();
if (haystack.includes('dessert') || haystack.includes('cake') || haystack.includes('cookie') || haystack.includes('sweet')) {
return 'dessert';
}
if (haystack.includes('breakfast') || haystack.includes('toast') || haystack.includes('egg')) {
return 'breakfast';
}
if (
haystack.includes('dinner') ||
haystack.includes('main') ||
haystack.includes('pasta') ||
haystack.includes('stew') ||
haystack.includes('soup')
) {
return 'dinner';
}
return null;
}
function listFallbackFromManifest(photoManifest?: PhotoManifest | null): string {
return photoManifest?.assets?.photos?.list?.default4x3 || DEFAULT_LIST_FALLBACK;
}
function categoryImageFromManifest(
key: 'breakfast' | 'dinner' | 'dessert',
photoManifest?: PhotoManifest | null,
): string | null {
const categoryAsset = photoManifest?.assets?.photos?.category?.[key];
if (!categoryAsset) return null;
if (categoryAsset.localPath && categoryAsset.localPath.trim().length > 0) {
return categoryAsset.localPath;
}
if (categoryAsset.remoteCandidate && categoryAsset.remoteCandidate.trim().length > 0) {
return categoryAsset.remoteCandidate;
}
return null;
}
function imageForRecipe(recipe: Recipe, tags: Tag[], photoManifest?: PhotoManifest | null): string {
const directRecipeImage = getRecipeImageUrl(recipe);
if (directRecipeImage) return directRecipeImage;
const categoryKey = recipeCategoryKey(recipe, tags);
if (categoryKey) {
const categoryImage = categoryImageFromManifest(categoryKey, photoManifest);
if (categoryImage) return categoryImage;
}
return listFallbackFromManifest(photoManifest);
}
function Badge({ label, value }: { label: string; value: string }) {
return ( return (
<span className="ui-chip border-[var(--border)]/80 bg-white/90 px-2.5 py-1 text-xs font-semibold text-[var(--text)]"> <Link
<span className="text-[var(--text-dim)]">{label}</span> to={`/recipe/${recipe.id}`}
<span>{value}</span> className="block bg-white border border-gray-200 rounded-xl shadow-card hover:shadow-lg hover:border-blue-300 transition-shadow group outline-none focus-visible:ring-2 focus-visible:ring-blue-600 min-h-[200px]"
</span> style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
); >
} <div className="p-5 flex flex-col h-full">
{/* Title */}
<h3 className="text-lg font-bold text-gray-900 mb-1 line-clamp-2 group-hover:text-blue-700 transition-colors">{recipe.title}</h3>
{/* Description */}
{recipe.description && <p className="text-xs text-gray-600 mb-2 line-clamp-2">{recipe.description}</p>}
export function RecipeCard({ recipe, tags = [], viewMode = 'grid', photoManifest = null }: RecipeCardProps) { {/* Tags */}
const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0); {tags.length > 0 && (
const difficulty = inferDifficulty(recipe); <div className="flex flex-wrap gap-1 mb-2">
const category = categoryForRecipe(tags); {tags.map(tag => (
const imageSrc = imageForRecipe(recipe, tags, photoManifest);
const listFallback = listFallbackFromManifest(photoManifest);
if (viewMode === 'list') {
return (
<Link
to={`/recipe/${recipe.id}`}
className="ui-card group grid grid-cols-1 gap-3 overflow-hidden border-[var(--border)]/85 bg-[var(--surface)]/95 p-3 outline-none transition-all duration-200 hover:border-[var(--color-primary)]/35 hover:[box-shadow:var(--shadow-hover)] md:grid-cols-[minmax(0,2.2fr)_minmax(0,1.4fr)_minmax(0,1fr)_auto] md:items-center md:gap-4 md:p-4"
style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
>
<div className="min-w-0 md:pr-2">
<p className="mb-1 hidden text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--text-dim)]/85 md:block">Title</p>
<div className="flex min-w-0 items-start gap-2.5">
<div className="mt-0.5 hidden h-10 w-10 shrink-0 overflow-hidden rounded-md border border-[var(--border)]/70 bg-[var(--surface-muted)] lg:block">
<img
src={imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
onError={(e) => {
e.currentTarget.src = listFallback;
}}
/>
</div>
<div className="min-w-0">
<h3
className="line-clamp-1 text-base font-bold text-[var(--text-h)] transition-colors duration-200 group-hover:text-[var(--color-primary-dark)] sm:text-lg"
style={{ fontSize: typography.fontSize.lg }}
>
{recipe.title}
</h3>
{recipe.description ? (
<p className="mt-1 line-clamp-2 text-sm text-[var(--text-dim)] md:line-clamp-1">{recipe.description}</p>
) : (
<p className="mt-1 text-sm italic text-[var(--text-dim)]/70">No description yet</p>
)}
</div>
</div>
</div>
<div className="min-w-0">
<p className="mb-1 hidden text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--text-dim)]/85 md:block">Tags</p>
<div className="flex flex-wrap gap-1.5">
{tags.slice(0, 3).map((tag) => (
<span <span
key={tag.id} key={tag.id}
className="ui-chip px-2 py-0.5 text-[11px] font-semibold text-white shadow-sm" className="px-2 py-0.5 rounded-full text-xs font-semibold text-white shadow"
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }} style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }}
> >
{tag.name} {tag.name}
</span> </span>
))} ))}
{tags.length > 3 ? (
<span className="ui-chip bg-[var(--surface-muted)] px-2 py-0.5 text-[11px] font-medium text-[var(--text-dim)]">
+{tags.length - 3}
</span>
) : null}
{tags.length === 0 ? <span className="text-sm text-[var(--text-dim)]/80">No tags</span> : null}
</div> </div>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-[var(--text-dim)] sm:text-sm md:grid-cols-1 md:gap-y-1.5">
<p className="mb-0.5 col-span-2 hidden text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--text-dim)]/85 md:block">Details</p>
<div>
<span className="font-semibold text-[var(--text)]">Time:</span> {totalTime > 0 ? formatTime(totalTime) : 'Flexible'}
</div>
<div>
<span className="font-semibold text-[var(--text)]">Difficulty:</span> {difficulty}
</div>
<div>
<span className="font-semibold text-[var(--text)]">Category:</span> {category}
</div>
<div>
<span className="font-semibold text-[var(--text)]">Last:</span> {recipe.last_cooked_at ? formatDate(recipe.last_cooked_at) : 'Never'}
</div>
</div>
<div className="hidden self-center text-right text-sm font-semibold text-[var(--color-primary)] md:block">
View
</div>
</Link>
);
}
return (
<Link
to={`/recipe/${recipe.id}`}
className="ui-card group flex h-full flex-col overflow-hidden bg-[var(--surface)]/95 outline-none transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--color-primary)]/35 hover:[box-shadow:var(--shadow-hover)]"
style={{ boxShadow: shadows.card, borderRadius: radius.lg }}
>
<div className="relative h-48 overflow-hidden border-[var(--border)] border-b">
<img
src={imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
onError={(e) => {
e.currentTarget.src = listFallback;
}}
/>
<div
className="pointer-events-none absolute inset-0"
style={{ background: 'linear-gradient(to top, rgba(28, 25, 23, 0.45), rgba(28, 25, 23, 0.1), transparent)' }}
/>
<div className="absolute bottom-3 left-3 right-3 flex flex-wrap gap-1.5">
{totalTime > 0 ? <Badge label="Time" value={formatTime(totalTime)} /> : <Badge label="Time" value="Flexible" />}
<Badge label="Category" value={category} />
<Badge label="Difficulty" value={difficulty} />
</div>
</div>
<div className="flex min-h-[220px] flex-1 flex-col p-5">
<h3
className="mb-2 line-clamp-2 text-lg font-bold text-[var(--text-h)] transition-colors duration-200 group-hover:text-[var(--color-primary-dark)]"
style={{ fontSize: typography.fontSize.lg }}
>
{recipe.title}
</h3>
{recipe.description ? (
<p className="mb-3 line-clamp-2 text-sm text-[var(--text-dim)]">{recipe.description}</p>
) : (
<p className="mb-3 text-sm italic text-[var(--text-dim)]/70">No description yet</p>
)} )}
<div className="mb-3 flex flex-wrap gap-1.5"> {/* Meta information */}
{tags.slice(0, 4).map((tag) => ( <div className="flex flex-wrap gap-3 text-xs text-gray-500 mb-2">
<span {recipe.servings && (
key={tag.id} <div className="flex items-center gap-1">
className="ui-chip px-2.5 py-1 text-xs font-semibold text-white shadow-sm" <span>🍽</span>
style={{ backgroundColor: tag.color || colors.primary, borderRadius: radius.full }} <span>{recipe.servings} servings</span>
> </div>
{tag.name} )}
</span> {totalTime > 0 && (
))} <div className="flex items-center gap-1">
{tags.length > 4 ? ( <span></span>
<span className="ui-chip bg-[var(--surface-muted)] px-2 py-1 text-xs font-medium text-[var(--text-dim)]"> <span>{formatTime(totalTime)}</span>
+{tags.length - 4} </div>
</span> )}
) : null} {recipe.last_cooked_at && (
<div className="flex items-center gap-1">
<span>👨🍳</span>
<span>Last cooked {formatDate(recipe.last_cooked_at)}</span>
</div>
)}
</div> </div>
<div className="mt-auto flex items-center justify-between border-t border-[var(--border)]/70 pt-3 text-xs text-[var(--text-dim)]"> <div className="mt-auto pt-3 border-t border-gray-100 flex justify-between items-center text-xs text-gray-500">
<span>{recipe.last_cooked_at ? `Cooked ${formatDate(recipe.last_cooked_at)}` : 'Not cooked yet'}</span> <span>{recipe.ingredients.length} ingredients</span>
<span className="inline-flex items-center gap-1 font-medium text-[var(--color-primary)]"> <span className="text-blue-600 font-medium group-hover:underline">View Recipe </span>
View recipe
<span className="transition-transform duration-200 group-hover:translate-x-0.5" aria-hidden="true">
</span>
</span>
</div> </div>
</div> </div>
</Link> </Link>

View File

@ -119,26 +119,26 @@ export function RecipeForm({
return ( return (
<form onSubmit={handleSubmit} className="space-y-8"> <form onSubmit={handleSubmit} className="space-y-8">
{error && ( {error && (
<div className="rounded-lg border border-[var(--color-error)]/25 bg-[var(--color-error-light)] px-5 py-3 text-base font-medium text-[var(--color-error)] shadow-card"> <div className="bg-red-50 border border-red-200 text-red-700 px-5 py-3 rounded-lg shadow-card font-medium text-base">
{error} {error}
</div> </div>
)} )}
<div> <div>
<label htmlFor="title" className="block text-base font-semibold text-[var(--text)] mb-1"> <label htmlFor="title" className="block text-base font-semibold text-gray-700 mb-1">
Title <span className="text-[var(--color-error)]">*</span> Title <span className="text-error">*</span>
</label> </label>
<input <input
type="text" type="text"
id="title" id="title"
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
className="ui-input text-[17px] font-medium" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-[17px] py-2 px-4 font-medium"
placeholder="e.g., Chocolate Chip Cookies" placeholder="e.g., Chocolate Chip Cookies"
required required
/> />
</div> </div>
<div> <div>
<label htmlFor="description" className="block text-base font-semibold text-[var(--text)] mb-1"> <label htmlFor="description" className="block text-base font-semibold text-gray-700 mb-1">
Description Description
</label> </label>
<textarea <textarea
@ -146,103 +146,103 @@ export function RecipeForm({
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={e => setDescription(e.target.value)}
rows={2} rows={2}
className="ui-textarea text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
placeholder="Brief description of the recipe..." placeholder="Brief description of the recipe..."
/> />
</div> </div>
<div> <div>
<label className="block text-base font-semibold text-[var(--text)] mb-1"> <label className="block text-base font-semibold text-gray-700 mb-1">
Tags Tags
</label> </label>
<TagSelector selectedTags={selectedTags} onTagsChange={setSelectedTags} /> <TagSelector selectedTags={selectedTags} onTagsChange={setSelectedTags} />
</div> </div>
<div> <div>
<label htmlFor="ingredients" className="block text-base font-semibold text-[var(--text)] mb-1"> <label htmlFor="ingredients" className="block text-base font-semibold text-gray-700 mb-1">
Ingredients <span className="text-[var(--color-error)]">*</span> Ingredients <span className="text-error">*</span>
</label> </label>
<p className="mt-0.5 text-sm text-[var(--text-dim)] mb-2">One ingredient per line</p> <p className="mt-0.5 text-sm text-gray-500 mb-2">One ingredient per line</p>
<textarea <textarea
id="ingredients" id="ingredients"
value={ingredientsText} value={ingredientsText}
onChange={e => setIngredientsText(e.target.value)} onChange={e => setIngredientsText(e.target.value)}
rows={7} rows={7}
className="ui-textarea font-mono text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary font-mono text-base px-4 py-2"
placeholder="2 cups all-purpose flour\n1 cup butter, softened\n3/4 cup sugar" placeholder="2 cups all-purpose flour\n1 cup butter, softened\n3/4 cup sugar"
required required
/> />
</div> </div>
<div> <div>
<label htmlFor="instructions" className="block text-base font-semibold text-[var(--text)] mb-1"> <label htmlFor="instructions" className="block text-base font-semibold text-gray-700 mb-1">
Instructions <span className="text-[var(--color-error)]">*</span> Instructions <span className="text-error">*</span>
</label> </label>
<p className="mt-0.5 text-sm text-[var(--text-dim)] mb-2">One step per line</p> <p className="mt-0.5 text-sm text-gray-500 mb-2">One step per line</p>
<textarea <textarea
id="instructions" id="instructions"
value={instructionsText} value={instructionsText}
onChange={e => setInstructionsText(e.target.value)} onChange={e => setInstructionsText(e.target.value)}
rows={8} rows={8}
className="ui-textarea font-mono text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary font-mono text-base px-4 py-2"
placeholder="Preheat oven to 350°F\nMix flour and baking soda\nCream butter and sugar" placeholder="Preheat oven to 350°F\nMix flour and baking soda\nCream butter and sugar"
required required
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<label htmlFor="servings" className="block text-base font-semibold text-[var(--text)] mb-1">Servings</label> <label htmlFor="servings" className="block text-base font-semibold text-gray-700 mb-1">Servings</label>
<input <input
type="number" type="number"
id="servings" id="servings"
value={servings} value={servings}
onChange={e => setServings(e.target.value)} onChange={e => setServings(e.target.value)}
min="1" min="1"
className="ui-input text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
placeholder="4" placeholder="4"
/> />
</div> </div>
<div> <div>
<label htmlFor="prep_time" className="block text-base font-semibold text-[var(--text)] mb-1">Prep Time (min)</label> <label htmlFor="prep_time" className="block text-base font-semibold text-gray-700 mb-1">Prep Time (min)</label>
<input <input
type="number" type="number"
id="prep_time" id="prep_time"
value={prepTime} value={prepTime}
onChange={e => setPrepTime(e.target.value)} onChange={e => setPrepTime(e.target.value)}
min="0" min="0"
className="ui-input text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
placeholder="15" placeholder="15"
/> />
</div> </div>
<div> <div>
<label htmlFor="cook_time" className="block text-base font-semibold text-[var(--text)] mb-1">Cook Time (min)</label> <label htmlFor="cook_time" className="block text-base font-semibold text-gray-700 mb-1">Cook Time (min)</label>
<input <input
type="number" type="number"
id="cook_time" id="cook_time"
value={cookTime} value={cookTime}
onChange={e => setCookTime(e.target.value)} onChange={e => setCookTime(e.target.value)}
min="0" min="0"
className="ui-input text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
placeholder="30" placeholder="30"
/> />
</div> </div>
</div> </div>
<div> <div>
<label htmlFor="source_url" className="block text-base font-semibold text-[var(--text)] mb-1">Source URL</label> <label htmlFor="source_url" className="block text-base font-semibold text-gray-700 mb-1">Source URL</label>
<input <input
type="url" type="url"
id="source_url" id="source_url"
value={sourceUrl} value={sourceUrl}
onChange={e => setSourceUrl(e.target.value)} onChange={e => setSourceUrl(e.target.value)}
className="ui-input text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
placeholder="https://example.com/recipe" placeholder="https://example.com/recipe"
/> />
</div> </div>
<div> <div>
<label htmlFor="notes" className="block text-base font-semibold text-[var(--text)] mb-1">Notes</label> <label htmlFor="notes" className="block text-base font-semibold text-gray-700 mb-1">Notes</label>
<textarea <textarea
id="notes" id="notes"
value={notes} value={notes}
onChange={e => setNotes(e.target.value)} onChange={e => setNotes(e.target.value)}
rows={3} rows={3}
className="ui-textarea text-base" className="mt-1 block w-full rounded-md border-gray-300 shadow-card focus:border-primary focus:ring-primary text-base px-4 py-2"
placeholder="Personal notes, substitutions, tips..." placeholder="Personal notes, substitutions, tips..."
/> />
</div> </div>
@ -250,7 +250,7 @@ export function RecipeForm({
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="ui-btn ui-btn-primary flex-1 px-4 py-2 text-base disabled:cursor-not-allowed disabled:opacity-60" className="flex-1 bg-primary text-white px-4 py-2 rounded-md shadow-card hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-semibold transition-colors text-base"
> >
{isSubmitting ? 'Saving...' : submitLabel} {isSubmitting ? 'Saving...' : submitLabel}
</button> </button>
@ -258,7 +258,7 @@ export function RecipeForm({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={isSubmitting}
className="ui-btn ui-btn-secondary px-4 py-2 text-base disabled:opacity-50 disabled:cursor-not-allowed" className="px-4 py-2 border border-gray-300 rounded-md shadow font-semibold text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed text-base transition-colors"
> >
Cancel Cancel
</button> </button>

View File

@ -5,7 +5,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTags } from '../hooks/useTags'; import { useTags } from '../hooks/useTags';
import { useToastContext } from '../App'; import { useToastContext } from '../App';
import { colors } from '../theme';
import type { Tag } from '../types/recipe'; import type { Tag } from '../types/recipe';
interface TagSelectorProps { interface TagSelectorProps {
@ -18,14 +17,14 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
const toast = useToastContext(); const toast = useToastContext();
const [showNewTagForm, setShowNewTagForm] = useState(false); const [showNewTagForm, setShowNewTagForm] = useState(false);
const [newTagName, setNewTagName] = useState(''); const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState<string>(colors.primary); const [newTagColor, setNewTagColor] = useState('#3B82F6');
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const handleToggleTag = (tag: Tag) => { const handleToggleTag = (tag: Tag) => {
const isSelected = selectedTags.some((t) => t.id === tag.id); const isSelected = selectedTags.some(t => t.id === tag.id);
if (isSelected) { if (isSelected) {
onTagsChange(selectedTags.filter((t) => t.id !== tag.id)); onTagsChange(selectedTags.filter(t => t.id !== tag.id));
} else { } else {
onTagsChange([...selectedTags, tag]); onTagsChange([...selectedTags, tag]);
} }
@ -41,7 +40,7 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
const newTag = await addTag(newTagName.trim(), newTagColor); const newTag = await addTag(newTagName.trim(), newTagColor);
onTagsChange([...selectedTags, newTag]); onTagsChange([...selectedTags, newTag]);
setNewTagName(''); setNewTagName('');
setNewTagColor(colors.primary); setNewTagColor('#3B82F6');
setShowNewTagForm(false); setShowNewTagForm(false);
toast.success(`Tag "${newTag.name}" created!`); toast.success(`Tag "${newTag.name}" created!`);
} catch (err) { } catch (err) {
@ -53,13 +52,13 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
}; };
if (loading) { if (loading) {
return <div className="text-[var(--text-dim)]">Loading tags...</div>; return <div className="text-gray-600">Loading tags...</div>;
} }
if (error) { if (error) {
return ( return (
<div className="rounded-md border border-[var(--color-error)]/25 bg-[var(--color-error-light)] p-3"> <div className="bg-red-50 border border-red-200 rounded-md p-3">
<p className="text-sm text-[var(--color-error)]">Error loading tags: {error}</p> <p className="text-red-700 text-sm">Error loading tags: {error}</p>
</div> </div>
); );
} }
@ -67,18 +66,25 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{tags.map((tag) => { {tags.map(tag => {
const isSelected = selectedTags.some((t) => t.id === tag.id); const isSelected = selectedTags.some(t => t.id === tag.id);
return ( return (
<button <button
key={tag.id} key={tag.id}
type="button" type="button"
onClick={() => handleToggleTag(tag)} onClick={() => handleToggleTag(tag)}
className={` className={`
ui-chip px-3 py-1 text-sm font-medium transition-colors px-3 py-1 rounded-full text-sm font-medium transition-colors
${isSelected ? 'text-white' : 'text-[var(--text)] hover:bg-[var(--surface-muted)]'} ${isSelected
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}
`} `}
style={isSelected ? { backgroundColor: tag.color || 'var(--color-primary)' } : {}} style={
isSelected && tag.color
? { backgroundColor: tag.color }
: {}
}
> >
{tag.name} {tag.name}
</button> </button>
@ -90,19 +96,19 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
<button <button
type="button" type="button"
onClick={() => setShowNewTagForm(true)} onClick={() => setShowNewTagForm(true)}
className="ui-btn ui-btn-secondary min-h-0 px-2 py-1 text-sm font-medium text-[var(--color-primary-dark)]" className="text-blue-600 hover:text-blue-700 text-sm font-medium"
> >
+ Create new tag + Create new tag
</button> </button>
) : ( ) : (
<form onSubmit={handleCreateTag} className="flex items-end gap-2"> <form onSubmit={handleCreateTag} className="flex gap-2 items-end">
<div className="flex-1"> <div className="flex-1">
<input <input
type="text" type="text"
value={newTagName} value={newTagName}
onChange={(e) => setNewTagName(e.target.value)} onChange={(e) => setNewTagName(e.target.value)}
placeholder="Tag name" placeholder="Tag name"
className="ui-input" className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus autoFocus
/> />
</div> </div>
@ -111,14 +117,14 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
type="color" type="color"
value={newTagColor} value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)} onChange={(e) => setNewTagColor(e.target.value)}
className="h-10 w-16 cursor-pointer rounded-md border border-[var(--border)] bg-[var(--surface)]" className="h-10 w-16 border border-gray-300 rounded-md cursor-pointer"
title="Tag color" title="Tag color"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={creating || !newTagName.trim()} disabled={creating || !newTagName.trim()}
className="ui-btn ui-btn-primary px-4 py-2 disabled:cursor-not-allowed disabled:opacity-60" className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
> >
{creating ? 'Creating...' : 'Add'} {creating ? 'Creating...' : 'Add'}
</button> </button>
@ -127,9 +133,9 @@ export function TagSelector({ selectedTags, onTagsChange }: TagSelectorProps) {
onClick={() => { onClick={() => {
setShowNewTagForm(false); setShowNewTagForm(false);
setNewTagName(''); setNewTagName('');
setNewTagColor(colors.primary); setNewTagColor('#3B82F6');
}} }}
className="ui-btn ui-btn-secondary px-4 py-2" className="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300"
> >
Cancel Cancel
</button> </button>

View File

@ -4,7 +4,6 @@
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
import { colors, radius, shadows, spacing, typography } from '../theme';
export type ToastType = 'success' | 'error' | 'info' | 'warning'; export type ToastType = 'success' | 'error' | 'info' | 'warning';
@ -32,11 +31,11 @@ export function Toast({ message, onClose }: ToastProps) {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [message.id, message.duration, onClose]); }, [message.id, message.duration, onClose]);
const backgroundColor = { const bgColor = {
success: colors.success, success: 'bg-green-600',
error: colors.error, error: 'bg-red-600',
info: colors.primary, info: 'bg-blue-600',
warning: colors.warning, warning: 'bg-yellow-600',
}[message.type]; }[message.type];
const icon = { const icon = {
@ -48,26 +47,15 @@ export function Toast({ message, onClose }: ToastProps) {
return ( return (
<div <div
className="animate-slide-in flex min-w-[300px] max-w-[500px] items-center justify-between gap-4 px-6 py-4 text-white" className={`${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center justify-between gap-4 min-w-[300px] max-w-[500px] animate-slide-in`}
style={{
backgroundColor,
borderRadius: radius.lg,
boxShadow: shadows.card,
}}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xl font-bold">{icon}</span> <span className="text-xl font-bold">{icon}</span>
<span <span className="font-medium">{message.message}</span>
className="font-medium"
style={{ fontSize: typography.fontSize.base, lineHeight: typography.lineHeight.normal }}
>
{message.message}
</span>
</div> </div>
<button <button
onClick={() => onClose(message.id)} onClick={() => onClose(message.id)}
className="text-xl font-bold leading-none text-white transition-colors hover:text-white/80" className="text-white hover:text-gray-200 text-xl font-bold leading-none"
style={{ marginInlineStart: spacing.xs }}
aria-label="Close" aria-label="Close"
> >
× ×
@ -88,7 +76,7 @@ export function ToastContainer({ messages, onClose }: ToastContainerProps) {
if (messages.length === 0) return null; if (messages.length === 0) return null;
return ( return (
<div className="fixed right-4 top-4 z-50 flex flex-col gap-2"> <div className="fixed top-4 right-4 z-50 flex flex-col gap-2">
{messages.map((message) => ( {messages.map((message) => (
<Toast key={message.id} message={message} onClose={onClose} /> <Toast key={message.id} message={message} onClose={onClose} />
))} ))}

View File

@ -1,5 +0,0 @@
export type ClassValue = string | false | null | undefined;
export function cn(...values: ClassValue[]): string {
return values.filter(Boolean).join(' ');
}

View File

@ -1,98 +0,0 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { cn } from './cn';
type UiTone = 'default' | 'subtle';
type UiButtonVariant = 'primary' | 'secondary';
type UiCardTone = 'default' | 'muted';
type UiSectionPadding = 'none' | 'md' | 'lg';
export function UiPage({ className, ...props }: ComponentPropsWithoutRef<'div'>) {
return <div className={cn('ui-page', className)} {...props} />;
}
export function UiSection({
className,
padding = 'md',
...props
}: ComponentPropsWithoutRef<'section'> & { padding?: UiSectionPadding }) {
const paddingClass =
padding === 'none' ? '' : padding === 'lg' ? 'px-5 py-6 md:px-6' : 'p-4 md:p-5';
return <section className={cn('ui-section', paddingClass, className)} {...props} />;
}
export function UiCard({
className,
tone = 'default',
...props
}: ComponentPropsWithoutRef<'article'> & { tone?: UiCardTone }) {
return (
<article
className={cn(
'ui-card',
tone === 'muted' ? 'bg-[var(--surface-muted)]/40' : '',
className,
)}
{...props}
/>
);
}
export function UiButton({
className,
variant = 'secondary',
type = 'button',
...props
}: ComponentPropsWithoutRef<'button'> & { variant?: UiButtonVariant }) {
return (
<button
type={type}
className={cn('ui-btn', variant === 'primary' ? 'ui-btn-primary' : 'ui-btn-secondary', className)}
{...props}
/>
);
}
export function UiLinkButton({
className,
variant = 'secondary',
...props
}: ComponentPropsWithoutRef<'a'> & { variant?: UiButtonVariant }) {
return (
<a
className={cn('ui-btn', variant === 'primary' ? 'ui-btn-primary' : 'ui-btn-secondary', className)}
{...props}
/>
);
}
export function UiChip({
className,
tone = 'default',
children,
...props
}: ComponentPropsWithoutRef<'span'> & { tone?: UiTone; children: ReactNode }) {
return (
<span
className={cn(
'ui-chip',
tone === 'subtle' ? 'bg-[var(--surface)] text-[var(--text-dim)]' : '',
className,
)}
{...props}
>
{children}
</span>
);
}
export function UiBadge({ className, children, ...props }: ComponentPropsWithoutRef<'span'> & { children: ReactNode }) {
return (
<span className={cn('ui-badge', className)} {...props}>
{children}
</span>
);
}

View File

@ -9,7 +9,7 @@ import type { Recipe } from '../types/recipe';
interface UseRecipesOptions { interface UseRecipesOptions {
search?: string; search?: string;
limit?: number; limit?: number;
tagIds?: number[]; tagId?: number | null;
} }
interface UseRecipesResult { interface UseRecipesResult {
@ -22,16 +22,13 @@ interface UseRecipesResult {
} }
export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult { export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult {
const { search = '', limit = 20, tagIds = [] } = options; const { search = '', limit = 20, tagId = null } = options;
const [recipes, setRecipes] = useState<Recipe[]>([]); const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [offset, setOffset] = useState(0); const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const normalizedTagIds = [...tagIds].sort((a, b) => a - b);
const tagIdsDependency = normalizedTagIds.join(',');
const loadRecipes = async (currentOffset: number, append: boolean = false) => { const loadRecipes = async (currentOffset: number, append: boolean = false) => {
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -40,7 +37,7 @@ export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult {
search: search || undefined, search: search || undefined,
offset: currentOffset, offset: currentOffset,
limit, limit,
tagIds: normalizedTagIds.length > 0 ? normalizedTagIds : undefined, tagId,
}); });
if (append) { if (append) {
setRecipes(prev => [...prev, ...data]); setRecipes(prev => [...prev, ...data]);
@ -60,7 +57,7 @@ export function useRecipes(options: UseRecipesOptions = {}): UseRecipesResult {
setOffset(0); setOffset(0);
setHasMore(true); setHasMore(true);
loadRecipes(0, false); loadRecipes(0, false);
}, [search, tagIdsDependency]); }, [search, tagId]);
const loadMore = () => { const loadMore = () => {
if (!loading && hasMore) { if (!loading && hasMore) {

View File

@ -1,236 +1,98 @@
@import './styles/tokens.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
--text: #374151;
--text-h: #1e293b;
--bg: #fff;
--bg-alt: #f9fafb;
--border: #e5e7eb;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.08);
--accent-border: rgba(170, 59, 255, 0.35);
--card-shadow: 0 2px 8px 0 rgba(28,30,34,0.08);
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #d1d5db;
--text-h: #f3f4f6;
--bg: #16171d;
--bg-alt: #1a1b20;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.11);
--accent-border: rgba(192, 132, 252, 0.33);
--card-shadow: 0 3px 14px 0 rgba(32,34,40,0.21);
}
}
body { body {
margin: 0; margin: 0;
font-family: var(--sans); font-family: var(--sans);
color: var(--text); color: var(--text);
line-height: var(--line-height-normal); background: var(--bg-alt);
background: var(--surface-gradient);
background-attachment: fixed;
} }
#root { #root {
min-height: 100vh; min-height: 100vh;
background: transparent; background: var(--bg-alt);
} }
input, input, button, textarea, select {
button,
textarea,
select {
font-family: inherit; font-family: inherit;
} }
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--heading);
color: var(--text-h);
}
a {
color: inherit;
}
@layer components {
.ui-page {
margin-left: auto;
margin-right: auto;
max-width: 72rem;
padding-left: 1rem;
padding-right: 1rem;
padding-bottom: 2rem;
}
@media (min-width: 768px) {
.ui-page {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
.ui-section {
border-radius: var(--radius-xl);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 92%, transparent);
box-shadow: var(--card-shadow);
}
.ui-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--card-shadow);
}
.ui-btn {
display: inline-flex;
min-height: 2.75rem;
align-items: center;
justify-content: center;
gap: var(--space-xs);
border-radius: var(--radius-md);
border: 1px solid transparent;
padding: 0.625rem 1rem;
font-size: var(--font-size-sm);
font-weight: 600;
line-height: 1.2;
text-decoration: none;
transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease,
transform 0.15s ease;
}
.ui-btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-subtle);
}
.ui-btn-primary {
background: var(--color-primary);
color: #fff;
}
.ui-btn-primary:hover {
background: var(--color-primary-dark);
}
.ui-btn-secondary {
border-color: var(--border);
background: var(--surface);
color: var(--text);
}
.ui-btn-secondary:hover {
background: var(--surface-muted);
}
.ui-input,
.ui-textarea,
.ui-select {
width: 100%;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow-subtle);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.ui-input,
.ui-select {
min-height: 2.75rem;
padding: 0.625rem 0.875rem;
}
.ui-textarea {
padding: 0.625rem 0.875rem;
}
.ui-chip,
.ui-badge {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: var(--radius-full);
border: 1px solid var(--border);
padding: 0.25rem 0.625rem;
font-size: var(--font-size-xs);
line-height: 1.3;
}
.ui-chip {
background: var(--surface-muted);
color: var(--text);
font-weight: 500;
}
.ui-badge {
background: var(--color-primary-light);
color: var(--color-primary-dark);
font-weight: 600;
}
}
button:focus-visible,
.ui-btn:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible,
a:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
input::placeholder,
textarea::placeholder {
color: color-mix(in srgb, var(--text-dim) 72%, transparent);
}
.shadow-card { .shadow-card {
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--surface);
box-shadow: var(--card-shadow) !important; box-shadow: var(--card-shadow) !important;
} }
/* Toast animation */
@keyframes slide-in { @keyframes slide-in {
from { from { transform: translateX(100%); opacity: 0; }
transform: translateX(100%); to { transform: translateX(0); opacity: 1; }
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
} }
.animate-slide-in { .animate-slide-in {
animation: slide-in 0.3s ease-out; animation: slide-in 0.3s ease-out;
} }
::-webkit-scrollbar { /* Recipe Manager visual polish */
width: 8px;
background: var(--surface-muted); ::-webkit-input-placeholder { color: #96a3b7; }
::-moz-placeholder { color: #96a3b7; }
:-ms-input-placeholder { color: #96a3b7; }
::placeholder { color: #96a3b7; }
input:focus, textarea:focus, select:focus { outline: 2px solid #3b82f6; outline-offset: 2px; }
button, .button, .btn {
transition: box-shadow 0.13s, background 0.13s, color 0.13s;
} }
.card, .shadow-card { border-radius: 1rem; box-shadow: var(--card-shadow); }
::-webkit-scrollbar {
width: 8px;
background: #f3f4f6;
}
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: var(--border); background: #e5e7eb;
border-radius: 7px; border-radius: 7px;
} }
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@media (max-width: 480px) { @media (max-width: 480px) {
.max-w-7xl, .max-w-7xl, .max-w-6xl, .max-w-4xl, .max-w-3xl, .max-w-2xl, .max-w-xl, .max-w-md, .max-w-sm { max-width: 100vw !important; }
.max-w-6xl, .p-8, .p-7, .p-6 { padding: 1rem !important; }
.max-w-4xl,
.max-w-3xl,
.max-w-2xl,
.max-w-xl,
.max-w-md,
.max-w-sm {
max-width: 100vw !important;
}
.p-8,
.p-7,
.p-6 {
padding: 1rem !important;
}
} }

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useRecipe } from '../hooks/useRecipe'; import { useRecipe } from '../hooks/useRecipe';
import { UiBadge, UiButton, UiPage, UiSection } from '../components/ui/primitives'; import { colors, radius, spacing, shadows } from '../theme';
/** /**
* CookModePage - Hands-free cooking interface with wake lock * CookModePage - Hands-free cooking interface with wake lock
@ -11,16 +11,20 @@ export function CookModePage() {
const recipeId = id ? parseInt(id, 10) : null; const recipeId = id ? parseInt(id, 10) : null;
const { recipe, loading, error } = useRecipe(recipeId); const { recipe, loading, error } = useRecipe(recipeId);
// Track checked ingredients and steps
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set()); const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set());
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(new Set()); const [checkedSteps, setCheckedSteps] = useState<Set<number>>(new Set());
// Wake lock state
const [wakeLock, setWakeLock] = useState<WakeLockSentinel | null>(null); const [wakeLock, setWakeLock] = useState<WakeLockSentinel | null>(null);
const [wakeLockSupported, setWakeLockSupported] = useState(false); const [wakeLockSupported, setWakeLockSupported] = useState(false);
// Check if Wake Lock API is supported
useEffect(() => { useEffect(() => {
setWakeLockSupported('wakeLock' in navigator); setWakeLockSupported('wakeLock' in navigator);
}, []); }, []);
// Request wake lock
const requestWakeLock = async () => { const requestWakeLock = async () => {
if (!wakeLockSupported) return; if (!wakeLockSupported) return;
try { try {
@ -28,38 +32,28 @@ export function CookModePage() {
const lock = await navigator.wakeLock.request('screen'); const lock = await navigator.wakeLock.request('screen');
setWakeLock(lock); setWakeLock(lock);
lock.addEventListener('release', () => setWakeLock(null)); lock.addEventListener('release', () => setWakeLock(null));
} catch { } catch (err) { /* ignore */ }
// ignore
}
}; };
// Release wake lock
const releaseWakeLock = async () => { const releaseWakeLock = async () => {
if (wakeLock) { if (wakeLock) {
await wakeLock.release(); await wakeLock.release();
setWakeLock(null); setWakeLock(null);
} }
}; };
const toggleWakeLock = () => { (wakeLock ? releaseWakeLock() : requestWakeLock()); };
const toggleWakeLock = () => { useEffect(() => () => { if (wakeLock) wakeLock.release(); }, [wakeLock]);
wakeLock ? releaseWakeLock() : requestWakeLock();
};
useEffect(() => {
return () => {
if (wakeLock) wakeLock.release();
};
}, [wakeLock]);
const toggleIngredient = (index: number) => { const toggleIngredient = (index: number) => {
setCheckedIngredients((prev) => { setCheckedIngredients(prev => {
const next = new Set(prev); const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index); next.has(index) ? next.delete(index) : next.add(index);
return next; return next;
}); });
}; };
const toggleStep = (index: number) => { const toggleStep = (index: number) => {
setCheckedSteps((prev) => { setCheckedSteps(prev => {
const next = new Set(prev); const next = new Set(prev);
next.has(index) ? next.delete(index) : next.add(index); next.has(index) ? next.delete(index) : next.add(index);
return next; return next;
@ -68,32 +62,27 @@ export function CookModePage() {
if (loading) { if (loading) {
return ( return (
<UiPage className="flex min-h-[50vh] items-center justify-center"> <div className="flex justify-center items-center min-h-[50vh]">
<div className="text-center"> <div className="text-center">
<div className="inline-block h-12 w-12 animate-spin rounded-full border-b-2 border-[var(--color-primary)]"></div> <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p className="mt-4 text-[var(--text-dim)]">Loading recipe...</p> <p className="mt-4 text-gray-600">Loading recipe...</p>
</div> </div>
</UiPage> </div>
); );
} }
if (error || !recipe) { if (error || !recipe) {
return ( return (
<UiPage className="mx-auto max-w-md py-8"> <div className="bg-red-50 border border-red-200 rounded-2xl p-8 max-w-md mx-auto shadow-card text-center">
<UiSection className="border-[var(--danger-border)] bg-[var(--danger-bg)] text-center"> <h2 className="text-2xl font-bold text-red-800 mb-2">Error Loading Recipe</h2>
<h2 className="mb-2 text-2xl font-bold text-[var(--danger-text)]">Error Loading Recipe</h2> <p className="text-red-600 mb-4">{error || 'Recipe not found'}</p>
<p className="mb-4 text-[var(--danger-text)]/90">{error || 'Recipe not found'}</p> <Link to="/" className="inline-block px-4 py-2 bg-red-600 text-white rounded-full hover:bg-red-700 transition-colors shadow">Back to Recipes</Link>
<Link to="/" className="ui-btn ui-btn-secondary border-[var(--danger-border)] bg-white text-[var(--danger-text)] hover:bg-white/90">Back to Recipes</Link> </div>
</UiSection>
</UiPage>
); );
} }
const instructions: string[] = Array.isArray(recipe.instructions) // Use fallback if recipe.instructions missing
? recipe.instructions const instructions: string[] = Array.isArray(recipe.instructions) ? recipe.instructions : recipe.steps?.map(s => s.instruction) || [];
: recipe.steps?.map((s) => s.instruction) || [];
const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients : []; const ingredients = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
const ingredientsTotal = ingredients.length; const ingredientsTotal = ingredients.length;
const stepsTotal = instructions.length; const stepsTotal = instructions.length;
const ingredientsChecked = checkedIngredients.size; const ingredientsChecked = checkedIngredients.size;
@ -101,122 +90,65 @@ export function CookModePage() {
const ingredientsProgress = ingredientsTotal > 0 ? (ingredientsChecked / ingredientsTotal) * 100 : 0; const ingredientsProgress = ingredientsTotal > 0 ? (ingredientsChecked / ingredientsTotal) * 100 : 0;
const stepsProgress = stepsTotal > 0 ? (stepsChecked / stepsTotal) * 100 : 0; const stepsProgress = stepsTotal > 0 ? (stepsChecked / stepsTotal) * 100 : 0;
const done = ingredientsChecked === ingredientsTotal && stepsChecked === stepsTotal;
return ( return (
<UiPage className="mx-auto max-w-3xl py-7"> <div className="max-w-3xl mx-auto py-7">
<UiSection className="mb-8" padding="lg"> <div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
<div className="mb-4 flex flex-wrap items-start justify-between gap-6"> <div className="flex items-start justify-between mb-4 gap-6 flex-wrap">
<div className="min-w-0 flex-1"> <div className="flex-1 min-w-0">
<h1 className="mb-2 break-words text-3xl font-bold text-[var(--text-h)]">{recipe.title}</h1> <h1 className="text-3xl font-bold text-gray-900 mb-2 break-words">{recipe.title}</h1>
{recipe.description && <p className="mb-1 break-words text-base text-[var(--text-dim)]">{recipe.description}</p>} {recipe.description && (<p className="text-gray-600 text-base mb-1 break-words">{recipe.description}</p>)}
</div> </div>
<Link to={`/recipe/${recipe.id}`} className="ui-btn ui-btn-secondary text-sm font-medium">Exit Cook Mode</Link> <Link to={`/recipe/${recipe.id}`} className="ml-4 px-4 py-2 text-blue-600 hover:bg-blue-50 rounded-md border border-blue-100 transition-colors text-sm font-medium shadow-sm">Exit Cook Mode</Link>
</div> </div>
<div className="flex flex-wrap gap-5 text-sm text-gray-600 mb-4">
<div className="mb-4 flex flex-wrap gap-3 text-sm"> {recipe.servings && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Servings: <span className="ml-1">{recipe.servings}</span></div>)}
{recipe.servings && <UiBadge>Servings: {recipe.servings}</UiBadge>} {recipe.prep_time_minutes && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Prep: <span className="ml-1">{recipe.prep_time_minutes} min</span></div>)}
{recipe.prep_time_minutes && <UiBadge>Prep: {recipe.prep_time_minutes} min</UiBadge>} {recipe.cook_time_minutes && (<div className="bg-gray-50 rounded-md px-3 py-1 font-medium shadow-inner">Cook: <span className="ml-1">{recipe.cook_time_minutes} min</span></div>)}
{recipe.cook_time_minutes && <UiBadge>Cook: {recipe.cook_time_minutes} min</UiBadge>}
</div> </div>
{wakeLockSupported && ( {wakeLockSupported && (
<div className="mt-4 border-t border-[var(--border)] pt-4"> <div className="border-t pt-4 mt-4">
<UiButton <button onClick={toggleWakeLock} className={`w-full sm:w-auto px-6 py-3 rounded-lg font-medium transition-colors focus:outline-none shadow ${wakeLock ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}`}>
onClick={toggleWakeLock}
variant={wakeLock ? 'primary' : 'secondary'}
className={wakeLock ? '' : 'text-[var(--text-dim)]'}
>
{wakeLock ? '🔒 Screen Locked (Stay Awake)' : '🔓 Screen Will Sleep (Tap to Lock)'} {wakeLock ? '🔒 Screen Locked (Stay Awake)' : '🔓 Screen Will Sleep (Tap to Lock)'}
</UiButton> </button>
<p className="mt-2 text-sm text-[var(--text-muted)]"> <p className="mt-2 text-sm text-gray-500">{wakeLock ? 'Your screen will stay on while cooking' : 'Enable to prevent your screen from turning off'}</p>
{wakeLock ? 'Your screen will stay on while cooking' : 'Enable to prevent your screen from turning off'} </div> )}
</p> </div>
</div> <div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
)} <div className="flex items-center justify-between mb-4"><h2 className="text-2xl font-bold text-gray-900">Ingredients</h2><div className="text-sm font-medium text-gray-600">{ingredientsChecked} of {ingredientsTotal}</div></div>
</UiSection> <div className="mb-6 bg-gray-200 rounded-full h-2 overflow-hidden">
<div className="bg-green-600 h-full transition-all duration-300" style={{ width: `${ingredientsProgress}%` }} />
<UiSection className="mb-8" padding="lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-2xl font-bold text-[var(--text-h)]">Ingredients</h2>
<div className="text-sm font-medium text-[var(--text-dim)]">{ingredientsChecked} of {ingredientsTotal}</div>
</div> </div>
<div className="mb-6 h-2 overflow-hidden rounded-full bg-[var(--border)]">
<div className="h-full bg-[var(--success)] transition-all duration-300" style={{ width: `${ingredientsProgress}%` }} />
</div>
<div className="space-y-3"> <div className="space-y-3">
{ingredients.map((ingredient: any, index: number) => ( {ingredients.map((ingredient: any, index: number) => (
<label <label key={index} className="flex items-start gap-3 p-3 rounded-lg border border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors shadow-sm">
key={index} <input type="checkbox" checked={checkedIngredients.has(index)} onChange={() => toggleIngredient(index)} className="mt-1 h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" />
className="flex cursor-pointer items-start gap-3 rounded-xl border border-[var(--border)] bg-white p-3 transition-colors hover:bg-[var(--surface-muted)]" <span className={`text-lg flex-1 ${checkedIngredients.has(index) ? 'line-through text-gray-500' : 'text-gray-900'}`}>{'item' in ingredient ? ingredient.item : typeof ingredient === 'string' ? ingredient : ''}</span>
>
<input
type="checkbox"
checked={checkedIngredients.has(index)}
onChange={() => toggleIngredient(index)}
className="mt-1 h-5 w-5 cursor-pointer rounded border-[var(--border-strong)] text-[var(--color-primary)] focus:ring-[var(--focus-ring)]"
/>
<span
className={`flex-1 text-lg ${checkedIngredients.has(index) ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}`}
>
{'item' in ingredient ? ingredient.item : typeof ingredient === 'string' ? ingredient : ''}
</span>
</label> </label>
))} ))}
</div> </div>
</UiSection> </div>
<div className="bg-white rounded-2xl shadow-card p-7 mb-8 border border-gray-100">
<UiSection className="mb-8" padding="lg"> <div className="flex items-center justify-between mb-4"><h2 className="text-2xl font-bold text-gray-900">Instructions</h2><div className="text-sm font-medium text-gray-600">{stepsChecked} of {stepsTotal}</div></div>
<div className="mb-4 flex items-center justify-between"> <div className="mb-6 bg-gray-200 rounded-full h-2 overflow-hidden"><div className="bg-blue-600 h-full transition-all duration-300" style={{ width: `${stepsProgress}%` }} /></div>
<h2 className="text-2xl font-bold text-[var(--text-h)]">Instructions</h2>
<div className="text-sm font-medium text-[var(--text-dim)]">{stepsChecked} of {stepsTotal}</div>
</div>
<div className="mb-6 h-2 overflow-hidden rounded-full bg-[var(--border)]">
<div className="h-full bg-[var(--color-primary)] transition-all duration-300" style={{ width: `${stepsProgress}%` }} />
</div>
<div className="space-y-4"> <div className="space-y-4">
{instructions.map((instruction, index) => ( {instructions.map((instruction, index) => (
<label <label key={index} className="flex items-start gap-4 p-4 border border-gray-100 rounded-xl hover:bg-gray-50 cursor-pointer transition-colors shadow-sm">
key={index}
className="flex cursor-pointer items-start gap-4 rounded-xl border border-[var(--border)] bg-white p-4 transition-colors hover:bg-[var(--surface-muted)]"
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center font-bold ${checkedSteps.has(index) ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600'}`}>{index + 1}</div>
className={`flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full font-bold ${ <input type="checkbox" checked={checkedSteps.has(index)} onChange={() => toggleStep(index)} className="h-5 w-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500 cursor-pointer" />
checkedSteps.has(index)
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--surface-muted)] text-[var(--text-dim)]'
}`}
>
{index + 1}
</div>
<input
type="checkbox"
checked={checkedSteps.has(index)}
onChange={() => toggleStep(index)}
className="h-5 w-5 cursor-pointer rounded border-[var(--border-strong)] text-[var(--color-primary)] focus:ring-[var(--focus-ring)]"
/>
</div> </div>
<span className={`flex-1 text-lg ${checkedSteps.has(index) ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}`}> <span className={`text-lg flex-1 ${checkedSteps.has(index) ? 'line-through text-gray-500' : 'text-gray-900'}`}>{instruction}</span>
{instruction}
</span>
</label> </label>
))} ))}
</div> </div>
</UiSection> </div>
{ingredientsChecked === ingredientsTotal && stepsChecked === stepsTotal && (
{done && ( <div className="bg-green-50 border-2 border-green-500 rounded-2xl p-7 mb-8 text-center shadow-card">
<UiSection className="mb-8 border-[var(--success-border)] bg-[var(--success-bg)] text-center" padding="lg"> <div className="text-4xl mb-3">🎉</div>
<div className="mb-3 text-4xl">🎉</div> <h3 className="text-2xl font-bold text-green-800 mb-2">All Done!</h3>
<h3 className="mb-2 text-2xl font-bold text-[var(--success-text)]">All Done!</h3> <p className="text-green-700 text-lg mb-4">You've completed all steps. Enjoy your meal!</p>
<p className="mb-4 text-lg text-[var(--success-text)]/90">You've completed all steps. Enjoy your meal!</p> <Link to={`/recipe/${recipe.id}`} className="inline-block px-6 py-3 bg-green-600 text-white rounded-full hover:bg-green-700 transition-colors font-medium shadow">Back to Recipe</Link>
<Link to={`/recipe/${recipe.id}`} className="ui-btn ui-btn-primary">Back to Recipe</Link> </div> )}
</UiSection> </div>
)}
</UiPage>
); );
} }

View File

@ -2,22 +2,16 @@ import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { createRecipe, importRecipeFromUrl } from '../services/api'; import { createRecipe, importRecipeFromUrl } from '../services/api';
import type { RecipeDraft, UrlImportResult } from '../types/recipe'; import type { RecipeDraft, UrlImportResult } from '../types/recipe';
import { UiButton, UiCard, UiChip, UiPage, UiSection } from '../components/ui/primitives'; import { colors, radius, shadows } from '../theme';
type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic'; type ImportErrorType = 'invalid-url' | 'parse-failure' | 'timeout' | 'generic';
type ImportStage = 'idle' | 'fetching' | 'parsing' | 'review' | 'saving' | 'done' | 'error';
function toTextBlock(items: string[]): string { function toTextBlock(items: string[]): string {
return items.join('\n'); return items.join('\n');
} }
function toList(text: string): string[] { function toList(text: string): string[] {
return text return text.split('\n').map((line) => line.trim()).filter((line) => line.length > 0);
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
} }
function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } { function getImportErrorDetails(message: string): { type: ImportErrorType; message: string } {
const normalized = message.toLowerCase(); const normalized = message.toLowerCase();
if (normalized.includes('valid url')) { if (normalized.includes('valid url')) {
@ -49,35 +43,16 @@ function getImportErrorDetails(message: string): { type: ImportErrorType; messag
message, message,
}; };
} }
// Converts recipe-draft shape (object[] — {item, ...}) to string[] for textarea editing
function draftIngredientsToStringArray(ingredients: RecipeDraft['ingredients']): string[] { function draftIngredientsToStringArray(ingredients: RecipeDraft['ingredients']): string[] {
if (!Array.isArray(ingredients)) return []; if (!Array.isArray(ingredients)) return [];
return ingredients.map((x) => return ingredients.map((x) => x && typeof x === 'object' && typeof x.item === 'string' ? x.item : String(x));
x && typeof x === 'object' && typeof x.item === 'string' ? x.item : String(x),
);
} }
// Converts string[] (from textarea) to recipe draft ingredient object[]
function ingredientStringsToDraftArray(strings: string[]): RecipeDraft['ingredients'] { function ingredientStringsToDraftArray(strings: string[]): RecipeDraft['ingredients'] {
return strings.map((s) => ({ item: s, quantity: null, unit: null, notes: null })); return strings.map((s) => ({ item: s, quantity: null, unit: null, notes: null }));
} }
function stageProgress(stage: ImportStage): number {
switch (stage) {
case 'fetching':
return 25;
case 'parsing':
return 60;
case 'review':
return 80;
case 'saving':
return 95;
case 'done':
return 100;
default:
return 0;
}
}
export function ImportUrlPage() { export function ImportUrlPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [url, setUrl] = useState<string>(''); const [url, setUrl] = useState<string>('');
@ -85,13 +60,14 @@ export function ImportUrlPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [errorType, setErrorType] = useState<ImportErrorType | null>(null); const [errorType, setErrorType] = useState<ImportErrorType | null>(null);
const [result, setResult] = useState<UrlImportResult | null>(null); const [result, setResult] = useState<UrlImportResult | null>(null);
// UI edit: keep ingredient/instructions as raw strings for editing, sync to draft before save
const [ingredientLines, setIngredientLines] = useState<string[]>([]); const [ingredientLines, setIngredientLines] = useState<string[]>([]);
const [instructionLines, setInstructionLines] = useState<string[]>([]); const [instructionLines, setInstructionLines] = useState<string[]>([]);
const [draft, setDraft] = useState<RecipeDraft | null>(null); const [draft, setDraft] = useState<RecipeDraft | null>(null);
const [draftError, setDraftError] = useState<string | null>(null); const [draftError, setDraftError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState<boolean>(false); const [isSaving, setIsSaving] = useState<boolean>(false);
const [stage, setStage] = useState<ImportStage>('idle');
// When result/draft loads from import, update the edit text states
useEffect(() => { useEffect(() => {
if (draft) { if (draft) {
setIngredientLines(draftIngredientsToStringArray(draft.ingredients)); setIngredientLines(draftIngredientsToStringArray(draft.ingredients));
@ -110,21 +86,16 @@ export function ImportUrlPage() {
setResult(null); setResult(null);
setDraft(null); setDraft(null);
setDraftError(null); setDraftError(null);
setStage('fetching');
try { try {
setTimeout(() => setStage((current) => (current === 'fetching' ? 'parsing' : current)), 450);
const imported: UrlImportResult = await importRecipeFromUrl(url); const imported: UrlImportResult = await importRecipeFromUrl(url);
setResult(imported); setResult(imported);
const importedDraft = imported.draft_recipe ?? null; const importedDraft = imported.draft_recipe ?? null;
setDraft(importedDraft); setDraft(importedDraft);
setStage('review');
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to import recipe URL'; const message = err instanceof Error ? err.message : 'Failed to import recipe URL';
const details = getImportErrorDetails(message); const details = getImportErrorDetails(message);
setErrorType(details.type); setErrorType(details.type);
setError(details.message); setError(details.message);
setStage('error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -136,11 +107,9 @@ export function ImportUrlPage() {
setDraftError('No draft recipe to save.'); setDraftError('No draft recipe to save.');
return; return;
} }
const title = draft.title.trim(); const title = draft.title.trim();
const ingredients = ingredientStringsToDraftArray(ingredientLines.filter(Boolean)); const ingredients = ingredientStringsToDraftArray(ingredientLines.filter(Boolean));
const instructions = instructionLines.filter(Boolean); const instructions = instructionLines.filter(Boolean);
if (!title) { if (!title) {
setDraftError('Title is required.'); setDraftError('Title is required.');
return; return;
@ -153,213 +122,78 @@ export function ImportUrlPage() {
setDraftError('At least one instruction step is required.'); setDraftError('At least one instruction step is required.');
return; return;
} }
setIsSaving(true); setIsSaving(true);
setDraftError(null); setDraftError(null);
setStage('saving');
try { try {
const created = await createRecipe({ ...draft, title, ingredients, instructions }); const created = await createRecipe({ ...draft, title, ingredients, instructions });
setStage('done');
navigate(`/recipe/${created.id}`); navigate(`/recipe/${created.id}`);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save recipe'; const message = err instanceof Error ? err.message : 'Failed to save recipe';
setDraftError(message); setDraftError(message);
setStage('error');
setIsSaving(false); setIsSaving(false);
} }
}; };
const progress = stageProgress(stage);
const stageOrder: ImportStage[] = ['idle', 'fetching', 'parsing', 'review', 'saving', 'done', 'error'];
const activeIndex = stageOrder.indexOf(stage);
return ( return (
<UiPage className="mx-auto max-w-3xl py-8"> <div className="max-w-2xl mx-auto py-8">
<UiSection className="relative mb-8 overflow-hidden" padding="lg"> <div className="bg-white rounded-2xl shadow-card p-7 border border-gray-100 mb-8">
<div className="absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-[var(--color-primary-soft)] via-[var(--surface-muted)] to-[var(--surface-elevated)]" /> <h2 className="text-2xl font-bold text-gray-900 mb-2">Import from URL</h2>
<div className="relative"> <p className="text-gray-600 mb-6">Paste a recipe URL and we'll try to fetch the page and extract recipe data.</p>
<div className="mb-5 flex items-start justify-between gap-4"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">Smart Import</p> <label htmlFor="import-url" className="block text-sm font-medium text-gray-700 mb-2">Recipe URL</label>
<h2 className="text-3xl font-bold text-[var(--text-h)]">Import from URL</h2> <input id="import-url" type="url" required value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://example.com/my-recipe" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-base shadow-sm" />
<p className="mt-1 text-[var(--text-dim)]">
Paste a recipe URL and we'll fetch, parse, and prep it for your cookbook.
</p>
</div>
<div className="hidden rounded-xl border border-[var(--border)] bg-white/70 p-4 text-3xl md:block">🔎</div>
</div> </div>
<button type="submit" disabled={loading} className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors shadow disabled:opacity-50 disabled:cursor-not-allowed">
<UiCard tone="muted" className="mb-5 p-3"> {loading ? 'Importing…' : 'Import URL'}
<div className="mb-2 flex items-center justify-between text-sm"> </button>
<span className="font-semibold text-[var(--text)]">Import Progress</span> </form>
<span className="text-[var(--text-dim)]">{progress}%</span> {error && (
</div> <div className={`mt-6 border rounded-lg p-4 ${errorType === 'parse-failure' ? 'bg-amber-50 border-amber-200' : 'bg-red-50 border-red-200'}`}>
<div className="h-2 overflow-hidden rounded-full bg-[var(--border)]"> <p className={errorType === 'parse-failure' ? 'text-amber-800' : 'text-red-800'}>
<div <strong>{errorType === 'invalid-url' && 'Invalid URL:'}{errorType === 'timeout' && 'Import timed out:'}{errorType === 'parse-failure' && 'Parse failed:'}{errorType === 'generic' && 'Error:'}</strong> {error}
className="h-full rounded-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-primary-dark)] transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs sm:grid-cols-4">
{[
{ key: 'fetching', label: 'Fetch page' },
{ key: 'parsing', label: 'Parse data' },
{ key: 'review', label: 'Review draft' },
{ key: 'saving', label: 'Save recipe' },
].map((step, index) => {
const stepIndex = index + 1;
const done = stage === 'done' || activeIndex > stepIndex;
const active = activeIndex === stepIndex;
return (
<div
key={step.key}
className={`rounded-md border px-2 py-1.5 ${
done
? 'border-[var(--success-border)] bg-[var(--success-bg)] text-[var(--success-text)]'
: active
? 'border-[var(--border-focus)] bg-[var(--color-primary-soft)] text-[var(--color-primary)]'
: 'border-[var(--border)] bg-white text-[var(--text-muted)]'
}`}
>
{done ? '✓ ' : active ? '● ' : '○ '} {step.label}
</div>
);
})}
</div>
</UiCard>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="import-url" className="mb-2 block text-sm font-medium text-[var(--text)]">
Recipe URL
</label>
<input
id="import-url"
type="url"
required
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://example.com/my-recipe"
className="ui-input"
/>
</div>
<UiButton type="submit" variant="primary" disabled={loading} className="disabled:cursor-not-allowed disabled:opacity-50">
{loading ? 'Importing…' : '🔗 Import URL'}
</UiButton>
</form>
{error && (
<div
className={`mt-6 rounded-lg border p-4 ${
errorType === 'parse-failure'
? 'border-[var(--warning-border)] bg-[var(--warning-bg)]'
: 'border-[var(--danger-border)] bg-[var(--danger-bg)]'
}`}
>
<p className={errorType === 'parse-failure' ? 'text-[var(--warning-text)]' : 'text-[var(--danger-text)]'}>
<strong>
{errorType === 'invalid-url' && 'Invalid URL:'}
{errorType === 'timeout' && 'Import timed out:'}
{errorType === 'parse-failure' && 'Parse failed:'}
{errorType === 'generic' && 'Error:'}
</strong>{' '}
{error}
</p>
</div>
)}
</div>
</UiSection>
{result && (
<UiSection className="mb-7 mt-1 space-y-5" padding="lg">
<UiCard className="bg-[var(--surface-muted)] p-4">
<h3 className="font-semibold text-[var(--text-h)]">Parsed Preview</h3>
<p className="text-sm text-[var(--text-dim)]">Source: {result.source_url}</p>
<p className="text-sm text-[var(--text-dim)]">
JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}
</p> </p>
</UiCard> </div>
)}
</div>
{result && (
<div className="mt-1 bg-white border border-gray-200 rounded-2xl p-7 space-y-4 shadow-card mb-7">
<div>
<h3 className="font-semibold text-gray-900">Parsed Preview</h3>
<p className="text-sm text-gray-600">Source: {result.source_url}</p>
<p className="text-sm text-gray-600">JSON-LD blocks found: {Array.isArray(result.json_ld_blocks) ? result.json_ld_blocks.length : 0}</p>
</div>
{draft ? ( {draft ? (
<form onSubmit={handleSave} className="space-y-5"> <form onSubmit={handleSave} className="space-y-5">
<p className="text-sm text-[var(--text-dim)]">Review and edit before saving.</p> <p className="text-sm text-gray-600">Review and edit before saving.</p>{draftError && (<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-red-800 text-sm mb-2">{draftError}</div>)}
{draftError && (
<div className="mb-2 rounded-lg border border-[var(--danger-border)] bg-[var(--danger-bg)] p-3 text-sm text-[var(--danger-text)]">
{draftError}
</div>
)}
<div> <div>
<label htmlFor="draft-title" className="mb-1 block text-sm font-medium text-[var(--text)]">Title</label> <label htmlFor="draft-title" className="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input <input id="draft-title" type="text" required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
id="draft-title"
type="text"
required
value={draft.title}
onChange={(event) => setDraft({ ...draft, title: event.target.value })}
className="ui-input"
/>
</div> </div>
<div> <div>
<label htmlFor="draft-ingredients" className="mb-1 block text-sm font-medium text-[var(--text)]"> <label htmlFor="draft-ingredients" className="block text-sm font-medium text-gray-700 mb-1">Ingredients (one per line)</label>
Ingredients (one per line) <textarea id="draft-ingredients" rows={8} value={toTextBlock(ingredientLines)} onChange={e => setIngredientLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
</label>
<textarea
id="draft-ingredients"
rows={8}
value={toTextBlock(ingredientLines)}
onChange={(e) => setIngredientLines(toList(e.target.value))}
className="ui-textarea"
/>
</div> </div>
<div> <div>
<label htmlFor="draft-instructions" className="mb-1 block text-sm font-medium text-[var(--text)]"> <label htmlFor="draft-instructions" className="block text-sm font-medium text-gray-700 mb-1">Steps (one per line)</label>
Steps (one per line) <textarea id="draft-instructions" rows={10} value={toTextBlock(instructionLines)} onChange={e => setInstructionLines(toList(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
</label>
<textarea
id="draft-instructions"
rows={10}
value={toTextBlock(instructionLines)}
onChange={(e) => setInstructionLines(toList(e.target.value))}
className="ui-textarea"
/>
</div> </div>
<div> <div>
<label htmlFor="draft-source-url" className="mb-1 block text-sm font-medium text-[var(--text)]"> <label htmlFor="draft-source-url" className="block text-sm font-medium text-gray-700 mb-1">Source URL</label>
Source URL <input id="draft-source-url" type="url" value={draft.source_url ?? ''} onChange={(event) => setDraft({ ...draft, source_url: event.target.value.trim() ? event.target.value : undefined })} className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm" />
</label>
<input
id="draft-source-url"
type="url"
value={draft.source_url ?? ''}
onChange={(event) =>
setDraft({
...draft,
source_url: event.target.value.trim() ? event.target.value : undefined,
})
}
className="ui-input"
/>
</div> </div>
<div className="flex gap-3 mt-2">
<div className="mt-2 flex flex-wrap gap-3"> <button type="submit" disabled={isSaving} className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed shadow">
<UiButton type="submit" variant="primary" disabled={isSaving} className="disabled:cursor-not-allowed disabled:opacity-50"> {isSaving ? 'Saving…' : 'Save Recipe'}
{isSaving ? 'Saving…' : '💾 Save Recipe'} </button>
</UiButton> <Link to="/recipe/new" className="px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 font-medium shadow-sm">Open full editor</Link>
<Link to="/recipe/new" className="ui-btn ui-btn-secondary">📝 Open full editor</Link>
</div> </div>
</form> </form>
) : ( ) : (
<UiChip className="border-[var(--warning-border)] bg-[var(--warning-bg)] text-[var(--warning-text)]"> <p className="text-amber-700 bg-amber-50 border border-amber-200 rounded p-3 text-sm">Could not parse a recipe preview from this URL.</p>
Could not parse a recipe preview from this URL.
</UiChip>
)} )}
</UiSection> </div>
)} )}
</UiPage> </div>
); );
} }

View File

@ -1,19 +1,19 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { UiPage, UiSection } from '../components/ui/primitives';
/** /**
* NotFoundPage - 404 error page * NotFoundPage - 404 error page
*/ */
export function NotFoundPage() { export function NotFoundPage() {
return ( return (
<UiPage> <div className="text-center py-12">
<UiSection className="py-12 text-center"> <h2 className="text-4xl font-bold text-gray-900 mb-4">404</h2>
<h2 className="mb-4 text-4xl font-bold text-[var(--text-h)]">404</h2> <p className="text-xl text-gray-600 mb-8">Page not found</p>
<p className="mb-8 text-xl text-[var(--text-dim)]">Page not found</p> <Link
<Link to="/" className="ui-btn ui-btn-primary inline-flex items-center"> to="/"
Back to Recipes className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
</Link> >
</UiSection> Back to Recipes
</UiPage> </Link>
</div>
); );
} }

View File

@ -4,199 +4,22 @@ import { useRecipe } from '../hooks/useRecipe';
import { useToastContext } from '../App'; import { useToastContext } from '../App';
import { RecipeForm, type RecipeFormData } from '../components/RecipeForm'; import { RecipeForm, type RecipeFormData } from '../components/RecipeForm';
import { createRecipe, updateRecipe, deleteRecipe, fetchRecipeTags, assignTagToRecipe, removeTagFromRecipe } from '../services/api'; import { createRecipe, updateRecipe, deleteRecipe, fetchRecipeTags, assignTagToRecipe, removeTagFromRecipe } from '../services/api';
import type { Tag, Ingredient, Recipe } from '../types/recipe'; import type { Tag, Recipe, Ingredient } from '../types/recipe';
import { loadPhotoManifest } from '../assets/photoManifest';
import { UiButton, UiCard, UiChip, UiPage, UiSection } from '../components/ui/primitives';
interface PhotoManifest {
assets?: {
photos?: {
detail?: {
default16x9?: string;
};
list?: {
default4x3?: string;
};
category?: Record<string, { localPath?: string | null; remoteCandidate?: string | null }>;
};
};
}
type IconName =
| 'clock'
| 'users'
| 'chef-hat'
| 'list'
| 'book-open'
| 'edit'
| 'cook'
| 'print'
| 'share'
| 'trash'
| 'arrow-left'
| 'link'
| 'note'
| 'tag';
function Icon({ name, className = 'h-4 w-4', size = 16 }: { name: IconName; className?: string; size?: number }) {
const common = {
className,
viewBox: '0 0 24 24',
width: size,
height: size,
style: {
width: `${size}px`,
height: `${size}px`,
minWidth: `${size}px`,
minHeight: `${size}px`,
maxWidth: `${size}px`,
maxHeight: `${size}px`,
flexShrink: 0,
},
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
switch (name) {
case 'clock':
return <svg {...common}><circle cx="12" cy="12" r="9" /><path d="M12 7v6l4 2" /></svg>;
case 'users':
return <svg {...common}><path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>;
case 'chef-hat':
return <svg {...common}><path d="M6 18h12" /><path d="M6 22h12" /><path d="M7 18v-7a4 4 0 1 1 7-2 3 3 0 1 1 4 3v6" /></svg>;
case 'list':
return <svg {...common}><path d="M8 6h13" /><path d="M8 12h13" /><path d="M8 18h13" /><path d="M3 6h.01" /><path d="M3 12h.01" /><path d="M3 18h.01" /></svg>;
case 'book-open':
return <svg {...common}><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" /><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" /></svg>;
case 'edit':
return <svg {...common}><path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>;
case 'cook':
return <svg {...common}><path d="M4 12h16" /><path d="M6 12v6a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2v-6" /><path d="M8 12V8a4 4 0 1 1 8 0v4" /></svg>;
case 'print':
return <svg {...common}><path d="M6 9V2h12v7" /><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" /><path d="M6 14h12v8H6z" /></svg>;
case 'share':
return <svg {...common}><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="m8.6 13.5 6.8 4" /><path d="m15.4 6.5-6.8 4" /></svg>;
case 'trash':
return <svg {...common}><path d="M3 6h18" /><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" /><path d="M10 11v6" /><path d="M14 11v6" /></svg>;
case 'arrow-left':
return <svg {...common}><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></svg>;
case 'link':
return <svg {...common}><path d="M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L10 5" /><path d="M14 11a5 5 0 0 0-7.07 0L4.1 13.83a5 5 0 1 0 7.07 7.07L14 19" /></svg>;
case 'note':
return <svg {...common}><path d="M8 2h8l4 4v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" /><path d="M16 2v4h4" /><path d="M10 13h4" /><path d="M10 17h4" /><path d="M10 9h2" /></svg>;
case 'tag':
return <svg {...common}><path d="M20.59 13.41 11 3.83A2 2 0 0 0 9.59 3H4a1 1 0 0 0-1 1v5.59A2 2 0 0 0 3.83 11l9.58 9.59a2 2 0 0 0 2.83 0l4.35-4.35a2 2 0 0 0 0-2.83Z" /><circle cx="7.5" cy="7.5" r="1.1" /></svg>;
default:
return null;
}
}
function normalizeImageUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (trimmed.startsWith('/') || trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
return null;
}
function extractRecipeImageUrl(recipe: Recipe | null): string | null {
if (!recipe) return null;
const dynamicRecipe = recipe as unknown as Record<string, unknown>;
const candidateKeys = [
'image_url',
'imageUrl',
'photo_url',
'photoUrl',
'hero_image_url',
'heroImageUrl',
'thumbnail_url',
'thumbnailUrl',
];
for (const key of candidateKeys) {
const normalized = normalizeImageUrl(dynamicRecipe[key]);
if (normalized) return normalized;
}
return null;
}
function inferCategoryKey(recipe: Recipe | null, tags: Tag[]): string | null {
const title = recipe?.title ?? '';
const haystack = `${title} ${tags.map((tag) => tag.name).join(' ')}`.toLowerCase();
if (haystack.includes('breakfast') || haystack.includes('egg') || haystack.includes('toast')) return 'breakfast';
if (haystack.includes('dessert') || haystack.includes('cake') || haystack.includes('cookie') || haystack.includes('sweet')) return 'dessert';
if (haystack.includes('dinner') || haystack.includes('steak') || haystack.includes('pasta') || haystack.includes('roast')) return 'dinner';
return null;
}
function buildDetailImageCandidates(recipe: Recipe | null, tags: Tag[], manifest: PhotoManifest | null): string[] {
const candidates: Array<string | null | undefined> = [];
const recipeImage = extractRecipeImageUrl(recipe);
candidates.push(recipeImage);
const categoryKey = inferCategoryKey(recipe, tags);
if (categoryKey && manifest?.assets?.photos?.category?.[categoryKey]) {
const categoryAsset = manifest.assets.photos.category[categoryKey];
candidates.push(categoryAsset.localPath ?? categoryAsset.remoteCandidate ?? null);
}
candidates.push(manifest?.assets?.photos?.detail?.default16x9);
candidates.push(manifest?.assets?.photos?.list?.default4x3);
candidates.push('/assets/food/placeholder-recipe.svg');
const seen = new Set<string>();
return candidates
.map((item) => normalizeImageUrl(item))
.filter((item): item is string => {
if (!item || seen.has(item)) return false;
seen.add(item);
return true;
});
}
/** /**
* RecipeDetailPage - View, create, and edit recipes (T06 detail visual upgrade) * RecipeDetailPage - View, create, and edit recipes (Visually polished)
*/ */
export function RecipeDetailPage() { export function RecipeDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToastContext(); const toast = useToastContext();
// Parse ID or null for "new" route
const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null); const recipeId = id === 'new' ? null : (id ? parseInt(id, 10) : null);
const { recipe, loading, error } = useRecipe(recipeId); const { recipe, loading, error } = useRecipe(recipeId);
const [isEditing, setIsEditing] = useState(recipeId === null); const [isEditing, setIsEditing] = useState(recipeId === null);
const [deleteConfirm, setDeleteConfirm] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(false);
const [recipeTags, setRecipeTags] = useState<Tag[]>([]); const [recipeTags, setRecipeTags] = useState<Tag[]>([]);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [photoManifest, setPhotoManifest] = useState<PhotoManifest | null>(null);
const [detailImageCandidates, setDetailImageCandidates] = useState<string[]>(['/assets/food/placeholder-recipe.svg']);
const [detailImageIndex, setDetailImageIndex] = useState(0);
useEffect(() => {
setDetailImageIndex(0);
}, [recipeId]);
useEffect(() => {
loadPhotoManifest<PhotoManifest>()
.then((manifest) => setPhotoManifest(manifest))
.catch(() => {
setPhotoManifest(null);
});
}, []);
useEffect(() => {
const candidates = buildDetailImageCandidates(recipe, recipeTags, photoManifest);
setDetailImageCandidates(candidates.length > 0 ? candidates : ['/assets/food/placeholder-recipe.svg']);
setDetailImageIndex(0);
}, [recipe, recipeTags, photoManifest]);
useEffect(() => { useEffect(() => {
if (recipeId !== null) { if (recipeId !== null) {
@ -209,6 +32,7 @@ export function RecipeDetailPage() {
} }
}, [recipeId, toast]); }, [recipeId, toast]);
// Compose FE ingredients to BE Ingredient[] shape with dummies for missing fields
function toApiIngredients(ingredients: string[]): Ingredient[] { function toApiIngredients(ingredients: string[]): Ingredient[] {
return ingredients.map((item, idx) => ({ return ingredients.map((item, idx) => ({
id: 0, id: 0,
@ -221,21 +45,17 @@ export function RecipeDetailPage() {
})); }));
} }
// Handle form submission
const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => { const handleSubmit = async (data: RecipeFormData, tags: Tag[]) => {
try { try {
if (recipeId === null) { if (recipeId === null) {
// Compose to API input shape (fill dummies)
const newRecipe = await createRecipe({ const newRecipe = await createRecipe({
...data, ...data,
ingredients: toApiIngredients(data.ingredients), ingredients: toApiIngredients(data.ingredients),
instructions: data.instructions, instructions: data.instructions,
}); });
for (const tag of tags) { for (const tag of tags) { try { await assignTagToRecipe(newRecipe.id, tag.id); } catch {} }
try {
await assignTagToRecipe(newRecipe.id, tag.id);
} catch {
// no-op
}
}
toast.success('Recipe created successfully!'); toast.success('Recipe created successfully!');
navigate(`/recipe/${newRecipe.id}`); navigate(`/recipe/${newRecipe.id}`);
} else { } else {
@ -244,25 +64,14 @@ export function RecipeDetailPage() {
ingredients: toApiIngredients(data.ingredients), ingredients: toApiIngredients(data.ingredients),
instructions: data.instructions, instructions: data.instructions,
}); });
const currentTagIds = recipeTags.map((t) => t.id); // Tag syncing (remove/add)
const newTagIds = tags.map((t) => t.id); const currentTagIds = recipeTags.map(t => t.id);
const newTagIds = tags.map(t => t.id);
for (const tagId of currentTagIds) { for (const tagId of currentTagIds) {
if (!newTagIds.includes(tagId)) { if (!newTagIds.includes(tagId)) { try { await removeTagFromRecipe(recipeId, tagId); } catch {} }
try {
await removeTagFromRecipe(recipeId, tagId);
} catch {
// no-op
}
}
} }
for (const tagId of newTagIds) { for (const tagId of newTagIds) {
if (!currentTagIds.includes(tagId)) { if (!currentTagIds.includes(tagId)) { try { await assignTagToRecipe(recipeId, tagId); } catch {} }
try {
await assignTagToRecipe(recipeId, tagId);
} catch {
// no-op
}
}
} }
toast.success('Recipe updated successfully!'); toast.success('Recipe updated successfully!');
setIsEditing(false); setIsEditing(false);
@ -290,308 +99,158 @@ export function RecipeDetailPage() {
} }
}; };
const handlePrint = () => { // Loading State
window.print();
};
const handleShare = async () => {
const title = recipe?.title ?? 'Recipe';
const shareText = recipe?.description ?? 'Check out this recipe';
const shareUrl = window.location.href;
try {
if (navigator.share) {
await navigator.share({
title,
text: shareText,
url: shareUrl,
});
return;
}
await navigator.clipboard.writeText(shareUrl);
toast.success('Recipe link copied to clipboard');
} catch {
toast.error('Could not share recipe right now');
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex flex-col items-center justify-center py-24"> <div className="flex flex-col items-center justify-center py-24">
<div className="inline-block h-9 w-9 animate-spin rounded-full border-b-2 border-primary"></div> <div className="inline-block animate-spin rounded-full h-9 w-9 border-b-2 border-primary"></div>
<p className="mt-6 text-base font-medium text-[var(--text-dim)]">Loading recipe...</p> <p className="mt-6 text-gray-500 text-base font-medium">Loading recipe...</p>
</div> </div>
); );
} }
// Error State
if (error) { if (error) {
return ( return (
<UiSection className="mx-auto mt-12 flex max-w-xl flex-col items-center border border-[color:var(--color-error-light)] bg-[color:var(--color-error-light)]/45 p-8 text-center" padding="none"> <div className="max-w-xl mx-auto bg-red-50 border border-red-200 rounded-xl shadow-card p-8 mt-12 flex flex-col items-center">
<h3 className="mb-3 text-xl font-bold text-[color:var(--color-error)]">Error Loading Recipe</h3> <h3 className="text-xl text-red-800 font-bold mb-3">Error Loading Recipe</h3>
<p className="mb-2 text-base text-[var(--text-dim)]">{error}</p> <p className="text-red-600 text-base mb-2">{error}</p>
<Link to="/" className="ui-btn ui-btn-primary mt-4"> Back to recipes</Link> <Link to="/" className="mt-4 px-4 py-2 bg-primary text-white rounded-md font-medium hover:bg-blue-700"> Back to recipes</Link>
</UiSection> </div>
); );
} }
// New Recipe
if (recipeId === null) { if (recipeId === null) {
return ( return (
<div className="mx-auto max-w-3xl pt-8"> <div className="max-w-2xl mx-auto pt-8">
<UiSection className="mb-6 overflow-hidden border border-[var(--border)] bg-gradient-to-br from-white via-[var(--surface-muted)]/70 to-[var(--bg-alt)]/70 shadow-card" padding="none"> <div className="mb-6 pb-1 border-b border-gray-200">
<div className="flex items-center justify-between px-6 py-5"> <h2 className="text-3xl font-bold text-gray-900">Create New Recipe</h2>
<div> <p className="mt-1 text-base text-gray-500">Fill in the details below to add a new recipe</p>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">New Recipe</p> </div>
<h2 className="text-3xl font-bold text-[var(--text-h)]">Create New Recipe</h2> <div className="bg-white rounded-xl shadow-card p-8">
<p className="mt-1 text-base text-[var(--text-dim)]">Fill in the details below to add a new recipe.</p>
</div>
<UiChip tone="subtle" className="hidden border-[var(--border)] bg-white/80 px-4 py-2 text-sm font-semibold text-[var(--color-primary)] md:inline-flex">Draft</UiChip>
</div>
</UiSection>
<UiCard className="p-8">
<RecipeForm initialTags={[]} onSubmit={handleSubmit} onCancel={() => navigate('/')} submitLabel="Create Recipe" /> <RecipeForm initialTags={[]} onSubmit={handleSubmit} onCancel={() => navigate('/')} submitLabel="Create Recipe" />
</UiCard> </div>
</div> </div>
); );
} }
// Recipe Not Found
if (!recipe) { if (!recipe) {
return ( return (
<UiSection className="mx-auto mt-12 flex max-w-md flex-col items-center border border-[color:var(--color-warning-light)] bg-[color:var(--color-warning-light)]/45 p-8 text-center" padding="none"> <div className="max-w-md mx-auto bg-yellow-50 border border-yellow-200 rounded-xl shadow-card p-8 mt-12 flex flex-col items-center">
<h3 className="mb-2 text-xl font-bold text-[color:var(--color-warning)]">Recipe Not Found</h3> <h3 className="text-xl text-yellow-800 font-bold mb-2">Recipe Not Found</h3>
<p className="mb-2 text-base text-[var(--text-dim)]">The recipe you are looking for does not exist.</p> <p className="text-yellow-600 text-base mb-2">The recipe you are looking for does not exist.</p>
<Link to="/" className="ui-btn ui-btn-primary mt-4"> Back to recipes</Link> <Link to="/" className="mt-4 px-4 py-2 bg-primary text-white rounded-md font-medium hover:bg-blue-700"> Back to recipes</Link>
</UiSection> </div>
); );
} }
// Edit Mode
if (isEditing) { if (isEditing) {
return ( return (
<div className="mx-auto max-w-3xl pt-8"> <div className="max-w-2xl mx-auto pt-8">
<UiSection className="mb-6 overflow-hidden border border-[var(--border)] bg-gradient-to-br from-white via-[var(--surface-muted)]/70 to-[var(--bg-alt)]/70 shadow-card" padding="none"> <div className="mb-6 pb-1 border-b border-gray-200">
<div className="flex items-center justify-between px-6 py-5"> <h2 className="text-3xl font-bold text-gray-900">Edit Recipe</h2>
<div> <p className="mt-1 text-base text-gray-500">Update recipe information below</p>
<p className="text-xs font-semibold uppercase tracking-wider text-[var(--color-primary)]">Editing</p> </div>
<h2 className="text-3xl font-bold text-[var(--text-h)]">Edit Recipe</h2> <div className="bg-white rounded-xl shadow-card p-8">
<p className="mt-1 text-base text-[var(--text-dim)]">Update recipe information below.</p>
</div>
<UiChip tone="subtle" className="hidden border-[var(--border)] bg-white/80 px-4 py-2 text-sm font-semibold text-[var(--color-primary)] md:inline-flex">Edit</UiChip>
</div>
</UiSection>
<UiCard className="p-8">
<RecipeForm recipe={recipe} initialTags={recipeTags} onSubmit={handleSubmit} onCancel={() => setIsEditing(false)} submitLabel="Save Changes" /> <RecipeForm recipe={recipe} initialTags={recipeTags} onSubmit={handleSubmit} onCancel={() => setIsEditing(false)} submitLabel="Save Changes" />
</UiCard> </div>
</div> </div>
); );
} }
const heroSrc = detailImageCandidates[Math.min(detailImageIndex, detailImageCandidates.length - 1)] ?? '/assets/food/placeholder-recipe.svg'; // View Recipe
const ingredients = Array.isArray(recipe.ingredients)
? recipe.ingredients
.map((ingredient) => ('item' in ingredient ? ingredient.item : String(ingredient)))
.filter(Boolean)
: [];
const instructions = Array.isArray(recipe.instructions)
? recipe.instructions.filter(Boolean)
: Array.isArray(recipe.steps)
? recipe.steps
.map((step) => step?.instruction)
.filter((instruction): instruction is string => Boolean(instruction))
: [];
const totalTime = (recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0);
const mobileActionButtonClass = 'ui-btn ui-btn-secondary min-h-[2.25rem] w-full min-w-0 justify-start gap-1.5 px-2.5 py-2 text-xs leading-4 sm:text-sm';
const mobilePrimaryActionButtonClass = 'ui-btn ui-btn-primary min-h-[2.25rem] w-full min-w-0 justify-start gap-1.5 px-2.5 py-2 text-xs leading-4 sm:text-sm';
const desktopActionButtonClass = 'ui-btn ui-btn-secondary w-full min-h-[2.5rem] min-w-0 justify-start gap-2 px-3 py-2 text-sm leading-4';
const metadataBadges = [
recipe.servings ? { label: `${recipe.servings} servings`, icon: 'users' as const } : null,
recipe.prep_time_minutes ? { label: `${recipe.prep_time_minutes} min prep`, icon: 'clock' as const } : null,
recipe.cook_time_minutes ? { label: `${recipe.cook_time_minutes} min cook`, icon: 'chef-hat' as const } : null,
totalTime > 0 ? { label: `${totalTime} min total`, icon: 'clock' as const } : null,
ingredients.length > 0 ? { label: `${ingredients.length} ingredients`, icon: 'list' as const } : null,
].filter(Boolean) as Array<{ label: string; icon: IconName }>;
return ( return (
<UiPage className="max-w-5xl px-4 pt-5 pb-20 sm:px-5 md:px-6 lg:px-8 md:pt-6 md:pb-8"> <div className="max-w-4xl mx-auto pt-8">
<section className="ui-section relative overflow-hidden"> <div className="bg-white rounded-xl shadow-card p-8 mb-6 flex flex-col sm:flex-row items-start justify-between gap-6">
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary-light)]/70 via-[var(--surface-muted)]/60 to-[var(--bg-alt)]/65" aria-hidden="true" /> <div className="flex-1 min-w-0">
<div className="relative grid grid-cols-1 gap-6 px-5 py-5 sm:px-6 sm:py-6 md:grid-cols-[1.2fr_1fr] md:items-stretch md:gap-8 md:px-8 md:py-7"> <h2 className="text-4xl font-bold text-gray-900 mb-1 break-words">{recipe.title}</h2>
<div className="min-w-0 space-y-5 px-0.5 sm:px-1 md:space-y-6 md:px-0"> {recipe.description && (
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-[var(--color-primary-dark)]">Recipe Detail</p> <p className="mt-1 text-lg text-gray-600 break-words">{recipe.description}</p>
<h1 className="max-w-[24ch] break-words text-xl font-black leading-tight text-[var(--text-h)] sm:text-2xl md:text-3xl">{recipe.title}</h1> )}
{recipe.description && ( {recipeTags.length > 0 && (
<p className="max-w-prose break-words text-sm leading-relaxed text-[var(--text-dim)] sm:text-base">{recipe.description}</p> <div className="mt-4 flex flex-wrap gap-2">
)} {recipeTags.map(tag => (
<span key={tag.id} className="px-3 py-1 rounded-full text-xs font-medium text-white shadow" style={{ backgroundColor: tag.color || '#3B82F6' }}>{tag.name}</span>
{metadataBadges.length > 0 && ( ))}
<div className="-mx-0.5 flex flex-wrap gap-2.5 px-0.5 pt-1 sm:mx-0 sm:px-0">
{metadataBadges.map((badge) => (
<UiChip key={badge.label} className="gap-1.5 border-[var(--border)] bg-white/90 px-3.5 py-1.5 text-xs font-semibold text-[var(--text)] shadow-sm">
<Icon name={badge.icon} className="h-3 w-3 text-[var(--text-dim)]" />
{badge.label}
</UiChip>
))}
</div>
)}
{recipeTags.length > 0 && (
<div className="-mx-0.5 flex flex-wrap gap-2.5 px-0.5 pt-1 sm:mx-0 sm:px-0">
{recipeTags.map((tag) => (
<span
key={tag.id}
className="inline-flex max-w-full items-center gap-2 rounded-full border border-white/70 px-4 py-2 text-xs font-semibold leading-4 text-[var(--text-h)] shadow-sm sm:px-5"
style={{ backgroundColor: `${tag.color || '#E2E8F0'}cc` }}
>
<Icon name="tag" className="h-3 w-3 shrink-0 text-[var(--text-dim)]" />
<span className="truncate">{tag.name}</span>
</span>
))}
</div>
)}
</div>
<div className="relative overflow-hidden rounded-2xl border border-white/70 bg-white/60 shadow-card">
<img
src={heroSrc}
alt={recipe.title}
className="h-full min-h-[240px] w-full object-cover"
onError={() => {
if (detailImageIndex < detailImageCandidates.length - 1) {
setDetailImageIndex((idx) => idx + 1);
}
}}
/>
<div className="absolute bottom-3 left-3 inline-flex max-w-[calc(100%-1.5rem)] items-center gap-1.5 rounded-full border border-white/80 bg-black/55 px-3 py-1 text-xs font-semibold text-white backdrop-blur">
<Icon name="book-open" className="h-3 w-3" />
{instructions.length > 0 ? `${instructions.length} steps` : 'Recipe overview'}
</div> </div>
</div>
</div>
</section>
<section className="mt-5 md:hidden min-w-0">
<div className="sticky bottom-3 z-20 rounded-2xl border border-[var(--border)] bg-white/95 p-2.5 shadow-[0_10px_30px_rgba(15,23,42,0.15)] backdrop-blur">
<div className="grid grid-cols-2 gap-2 min-w-0 text-xs">
<button onClick={() => setIsEditing(true)} className={mobileActionButtonClass}><Icon name="edit" className="h-3.5 w-3.5" size={14} /><span className="truncate">Edit</span></button>
<Link to={`/recipe/${recipe.id}/cook`} className={mobilePrimaryActionButtonClass}><Icon name="cook" className="h-3.5 w-3.5" size={14} /><span className="truncate">Cook</span></Link>
<button onClick={handlePrint} className={mobileActionButtonClass}><Icon name="print" className="h-3.5 w-3.5" size={14} /><span className="truncate">Print</span></button>
<button onClick={handleShare} className={mobileActionButtonClass}><Icon name="share" className="h-3.5 w-3.5" size={14} /><span className="truncate">Share</span></button>
{!deleteConfirm ? (
<UiButton onClick={() => setDeleteConfirm(true)} className="col-span-2 min-h-[2.25rem] w-full min-w-0 justify-center gap-1.5 border border-[color:var(--color-error-light)] bg-[color:var(--color-error-light)] px-2.5 py-2 text-xs leading-4 text-[color:var(--color-error)] sm:text-sm"><Icon name="trash" className="h-3.5 w-3.5" size={14} /><span className="truncate">Delete</span></UiButton>
) : (
<div className="col-span-2 grid grid-cols-1 gap-2">
<UiButton onClick={handleDelete} disabled={isDeleting} className="min-h-[2.25rem] w-full min-w-0 border border-[color:var(--color-error)] bg-[color:var(--color-error)] px-2.5 py-2 text-xs leading-4 text-white disabled:opacity-60 sm:text-sm">{isDeleting ? 'Deleting...' : 'Confirm delete'}</UiButton>
<UiButton onClick={() => setDeleteConfirm(false)} disabled={isDeleting} className="min-h-[2.25rem] w-full min-w-0 px-2.5 py-2 text-xs leading-4 sm:text-sm">Cancel</UiButton>
</div>
)}
</div>
</div>
</section>
<div className="mt-7 grid grid-cols-1 gap-7 min-w-0 lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="space-y-7 min-w-0">
<section className="ui-card overflow-hidden">
<div className="border-b border-[var(--border)] bg-gradient-to-r from-[var(--surface-muted)] to-white px-6 py-4 sm:px-7 md:px-8">
<h2 className="inline-flex items-center gap-2 text-lg sm:text-xl font-bold text-[var(--text-h)]"><Icon name="list" className="h-3.5 w-3.5 text-[var(--color-primary)]" />Ingredients</h2>
<p className="mt-1 text-sm leading-relaxed text-[var(--text-dim)]">Gather these first so cooking goes smoothly.</p>
</div>
<ul className="m-3 space-y-4 rounded-2xl border border-[var(--border)] bg-white/80 px-4 py-5 sm:m-4 sm:px-6 md:px-7 md:py-6">
{ingredients.length > 0 ? (
ingredients.map((ingredient, index) => (
<li
key={`${ingredient}-${index}`}
className="grid min-w-0 grid-cols-[1.85rem_minmax(0,1fr)] items-start gap-x-3.5 rounded-xl border border-[var(--border)] bg-[var(--surface-muted)]/60 px-4 py-3.5 sm:px-5"
>
<span className="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--color-primary-light)] text-xs font-semibold text-[var(--color-primary-dark)]">
{index + 1}
</span>
<span className="min-w-0 break-words pt-0.5 text-sm leading-6 text-[var(--text)]">{ingredient}</span>
</li>
))
) : (
<li className="rounded-xl border border-dashed border-[var(--border)] bg-white px-4 py-6 text-sm text-[var(--text-dim)] sm:px-5">
No ingredients listed yet.
</li>
)}
</ul>
</section>
<section className="ui-card overflow-hidden">
<div className="border-b border-[var(--border)] bg-gradient-to-r from-[var(--surface-muted)] to-white px-6 py-4 sm:px-7 md:px-8">
<h2 className="inline-flex items-center gap-2 text-lg sm:text-xl font-bold text-[var(--text-h)]"><Icon name="book-open" className="h-3.5 w-3.5 text-[var(--color-primary)]" />Instructions</h2>
<p className="mt-1 text-sm leading-relaxed text-[var(--text-dim)]">Step-by-step flow for better cooking rhythm.</p>
</div>
<ol className="m-3 space-y-4 rounded-2xl border border-[var(--border)] bg-white/80 px-4 py-5 sm:m-4 sm:px-6 md:px-7 md:py-6">
{instructions.length > 0 ? (
instructions.map((instruction, index) => (
<li key={`${instruction}-${index}`} className="grid min-w-0 grid-cols-[2rem_minmax(0,1fr)] items-start gap-x-3.5 rounded-xl border border-[var(--border)] bg-white px-4 py-4 sm:px-5">
<span className="mt-0.5 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--color-primary)] text-sm font-semibold text-white">
{index + 1}
</span>
<span className="min-w-0 break-words pt-1 text-sm leading-6 text-[var(--text)]">{instruction}</span>
</li>
))
) : (
<li className="rounded-xl border border-dashed border-[var(--border)] bg-white px-4 py-6 text-sm text-[var(--text-dim)] sm:px-5">
No instructions listed yet.
</li>
)}
</ol>
</section>
{(recipe.source_url || recipe.notes) && (
<section className="ui-card overflow-hidden">
<div className="border-b border-[var(--border)] bg-gradient-to-r from-[var(--surface-muted)] to-white px-6 py-4 sm:px-7 md:px-8">
<h3 className="text-lg sm:text-xl font-semibold text-[var(--text-h)]">Additional Information</h3>
</div>
<div className="m-3 space-y-5 rounded-2xl border border-[var(--border)] bg-white/80 px-4 py-5 sm:m-4 sm:px-6 md:px-7 md:py-6">
{recipe.source_url && (
<div className="space-y-2 rounded-xl border border-[var(--border)] bg-[var(--surface-muted)]/40 px-4 py-3.5 sm:px-5">
<div className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--text-dim)]"><Icon name="link" className="h-3 w-3" />Source</div>
<a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="break-words text-[var(--color-primary)] underline decoration-[1.5px] underline-offset-2 hover:text-[var(--color-primary-dark)]">
{recipe.source_url}
</a>
</div>
)}
{recipe.notes && (
<div className="space-y-2 rounded-xl border border-[var(--border)] bg-[var(--surface-muted)]/40 px-4 py-3.5 sm:px-5">
<div className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--text-dim)]"><Icon name="note" className="h-3 w-3" />Notes</div>
<p className="max-w-prose whitespace-pre-wrap break-words text-sm leading-relaxed text-[var(--text)] sm:text-base">{recipe.notes}</p>
</div>
)}
</div>
</section>
)} )}
</div> </div>
<div className="flex flex-col gap-3 min-w-[120px]">
<aside className="hidden lg:block"> <button onClick={() => setIsEditing(true)} className="w-full px-4 py-2 bg-primary text-white rounded-md shadow hover:bg-blue-700 font-medium transition-colors">Edit</button>
<div className="sticky top-6 space-y-3 rounded-2xl border border-[var(--border)] bg-white/95 p-4 shadow-card"> <Link to={`/recipe/${recipe.id}/cook`} className="w-full px-4 py-2 bg-success text-white rounded-md shadow hover:bg-green-700 font-medium text-center transition-colors">Cook Mode</Link>
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-[var(--text-dim)]">Quick Actions</p> {!deleteConfirm ? (
<button onClick={() => setIsEditing(true)} className={desktopActionButtonClass}><Icon name="edit" className="h-3.5 w-3.5" size={15} /><span className="truncate">Edit recipe</span></button> <button onClick={() => setDeleteConfirm(true)} className="w-full px-4 py-2 bg-error text-white rounded-md shadow font-medium hover:bg-red-700 transition-colors">Delete</button>
<Link to={`/recipe/${recipe.id}/cook`} className="ui-btn ui-btn-primary w-full min-h-[2.5rem] min-w-0 justify-start gap-2 px-3 py-2 text-sm leading-4"><Icon name="cook" className="h-3.5 w-3.5" size={15} /><span className="truncate">Open cook mode</span></Link> ) : (
<button onClick={handlePrint} className={desktopActionButtonClass}><Icon name="print" className="h-3.5 w-3.5" size={15} /><span className="truncate">Print recipe</span></button> <div className="flex flex-col gap-2">
<button onClick={handleShare} className={desktopActionButtonClass}><Icon name="share" className="h-3.5 w-3.5" size={15} /><span className="truncate">Share recipe</span></button> <button onClick={handleDelete} disabled={isDeleting} className="w-full px-3 py-2 bg-error text-white rounded-md hover:bg-red-700 text-sm font-medium shadow disabled:bg-gray-400 disabled:cursor-not-allowed">{isDeleting ? 'Deleting...' : 'Confirm Delete'}</button>
{!deleteConfirm ? ( <button onClick={() => setDeleteConfirm(false)} disabled={isDeleting} className="w-full px-3 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm font-medium shadow disabled:opacity-50 disabled:cursor-not-allowed">Cancel</button>
<UiButton onClick={() => setDeleteConfirm(true)} className="w-full min-h-[2.5rem] min-w-0 justify-start gap-2 border border-[color:var(--color-error-light)] bg-[color:var(--color-error-light)] px-3 py-2 text-sm leading-4 text-[color:var(--color-error)]"><Icon name="trash" className="h-3.5 w-3.5" size={15} /><span className="truncate">Delete recipe</span></UiButton> </div>
) : ( )}
<div className="grid grid-cols-2 gap-2"> </div>
<UiButton onClick={handleDelete} disabled={isDeleting} className="min-h-[2.5rem] min-w-0 border border-[color:var(--color-error)] bg-[color:var(--color-error)] px-3 py-2 text-sm leading-4 text-white disabled:opacity-60"> </div>
{isDeleting ? 'Deleting...' : 'Confirm'} <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6 text-center">
</UiButton> {recipe.servings && (
<UiButton onClick={() => setDeleteConfirm(false)} disabled={isDeleting} className="min-h-[2.5rem] min-w-0 px-3 py-2 text-sm leading-4"> <div className="bg-gray-50 rounded-lg p-4 shadow-card">
Cancel <div className="text-sm text-gray-500 mb-1">Servings</div>
</UiButton> <div className="text-lg font-semibold text-gray-900">{recipe.servings}</div>
</div>
)}
<Link to="/" className={desktopActionButtonClass}><Icon name="arrow-left" className="h-3.5 w-3.5" size={15} /><span className="truncate">Back to all recipes</span></Link>
</div> </div>
</aside> )}
{recipe.prep_time_minutes && (
<div className="bg-gray-50 rounded-lg p-4 shadow-card">
<div className="text-sm text-gray-500 mb-1">Prep Time</div>
<div className="text-lg font-semibold text-gray-900">{recipe.prep_time_minutes} min</div>
</div>
)}
{recipe.cook_time_minutes && (
<div className="bg-gray-50 rounded-lg p-4 shadow-card">
<div className="text-sm text-gray-500 mb-1">Cook Time</div>
<div className="text-lg font-semibold text-gray-900">{recipe.cook_time_minutes} min</div>
</div>
)}
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="mt-8 text-center lg:hidden"> <div className="bg-white rounded-xl shadow-card p-6">
<Link to="/" className="inline-flex items-center gap-1.5 text-sm font-medium text-[var(--color-primary)] transition-colors hover:text-[var(--color-primary-dark)]"><Icon name="arrow-left" className="h-3 w-3" />Back to all recipes</Link> <h3 className="text-xl font-semibold text-gray-900 mb-6">Ingredients</h3>
<ul className="space-y-3">
{Array.isArray(recipe.ingredients) ? recipe.ingredients.map((ingredient, index) => (
<li key={index} className="flex items-center gap-3">
<span className="inline-block w-3 h-3 bg-primary rounded-full"></span>
<span className="text-gray-800 font-mono text-base">{'item' in ingredient ? ingredient.item : ingredient}</span>
</li>
)) : null}
</ul>
</div>
<div className="bg-white rounded-xl shadow-card p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-6">Instructions</h3>
<ol className="space-y-4 list-none">
{Array.isArray(recipe.instructions) ? recipe.instructions.map((instruction, index) => (
<li key={index} className="flex items-start gap-3">
<span className="inline-flex items-center justify-center w-8 h-8 bg-primary text-white rounded-full text-base font-bold">{index + 1}</span>
<span className="text-gray-800 pt-[2px] text-base leading-6">{instruction}</span>
</li>
)) : null}
</ol>
</div>
</div> </div>
</UiPage> {(recipe.source_url || recipe.notes) && (
<div className="mt-8 bg-white rounded-xl shadow-card p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">Additional Information</h3>
{recipe.source_url && (
<div className="mb-4">
<div className="text-sm font-medium text-gray-700 mb-1">Source</div>
<a href={recipe.source_url} target="_blank" rel="noopener noreferrer" className="text-primary hover:text-blue-700 underline break-all">{recipe.source_url}</a>
</div>
)}
{recipe.notes && (
<div>
<div className="text-sm font-medium text-gray-700 mb-1">Notes</div>
<p className="text-gray-800 whitespace-pre-wrap">{recipe.notes}</p>
</div>
)}
</div>
)}
<div className="mt-8 text-center">
<Link to="/" className="text-primary hover:text-blue-700 font-medium"> Back to all recipes</Link>
</div>
</div>
); );
} }

Some files were not shown because too many files have changed in this diff Show More