Ingredients
{recipe.ingredients.map((ingredient, index) => (
// Ingredient lines can repeat, so the index keeps keys unique.
- - {ingredient}
+ - {convertText(ingredient, unitSystem)}
))}
@@ -130,7 +146,7 @@ export default function RecipeDetailPage() {
Directions
{recipe.directions.map((step, index) => (
- - {step}
+ - {convertText(step, unitSystem)}
))}
diff --git a/frontend/src/styles/Breadcrumbs.css b/frontend/src/styles/Breadcrumbs.css
new file mode 100644
index 0000000..df98023
--- /dev/null
+++ b/frontend/src/styles/Breadcrumbs.css
@@ -0,0 +1,28 @@
+.breadcrumbs ol {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.5rem;
+
+ margin: 0 0 1rem;
+ padding: 0;
+ list-style: none;
+
+ font-size: 0.9rem;
+}
+
+/* The separator is decoration, so it lives in CSS and is not read aloud. */
+.breadcrumbs li + li::before {
+ content: '/';
+ margin-right: 0.5rem;
+ color: var(--color-text);
+ opacity: 0.6;
+}
+
+.breadcrumbs a {
+ color: var(--color-rating);
+ font-weight: 600;
+}
+
+.breadcrumbs [aria-current='page'] {
+ color: var(--color-text);
+}
diff --git a/frontend/src/styles/HeroVideo.css b/frontend/src/styles/HeroVideo.css
new file mode 100644
index 0000000..14d1167
--- /dev/null
+++ b/frontend/src/styles/HeroVideo.css
@@ -0,0 +1,49 @@
+/* Fills the (position: relative) hero and sits behind its content. The hero
+ itself has a min-height, so the box is reserved before any media loads
+ (no layout shift). Opacity is set once on the wrapper, not on each child,
+ so the video does not blend with the poster underneath it. The low opacity
+ keeps the heading readable over real footage. */
+.hero-media {
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ overflow: hidden;
+
+ /* Light mode: dark footage at 0.25 would darken the page enough to bring
+ the gold heading (#8a5f1a) down to about 3:1 in the worst case, so it is
+ kept fainter there. Dark mode has plenty of headroom. */
+ opacity: 0.18;
+ background-color: var(--color-bg);
+}
+
+@media (prefers-color-scheme: dark) {
+ .hero-media {
+ opacity: 0.25;
+ }
+}
+
+/* cover: any viewport or orientation is filled without letterboxing. */
+.hero-media-item {
+ position: absolute;
+ inset: 0;
+
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.hero-video-toggle {
+ position: absolute;
+ right: 1rem;
+ bottom: 1rem;
+
+ padding: 0.4rem 0.75rem;
+
+ background-color: var(--color-surface);
+ color: var(--color-text);
+ border: 1px solid var(--color-border);
+ border-radius: 0.5rem;
+
+ font-size: 0.85rem;
+ cursor: pointer;
+}
diff --git a/frontend/src/styles/HomePage.css b/frontend/src/styles/HomePage.css
index beb3dc7..8186291 100644
--- a/frontend/src/styles/HomePage.css
+++ b/frontend/src/styles/HomePage.css
@@ -10,6 +10,8 @@
text-align: center;
position: relative;
+ /* Keeps the background video (z-index: -1) inside the hero. */
+ isolation: isolate;
font-size: 1.7rem;
}
diff --git a/frontend/src/styles/RecipeDetailPage.css b/frontend/src/styles/RecipeDetailPage.css
index 8de665f..11b1e94 100644
--- a/frontend/src/styles/RecipeDetailPage.css
+++ b/frontend/src/styles/RecipeDetailPage.css
@@ -101,7 +101,7 @@
.recipe-detail-nutrition dl {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
gap: 1rem;
margin: 0;
diff --git a/frontend/src/styles/UnitToggle.css b/frontend/src/styles/UnitToggle.css
new file mode 100644
index 0000000..20d510f
--- /dev/null
+++ b/frontend/src/styles/UnitToggle.css
@@ -0,0 +1,23 @@
+.unit-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 1rem;
+
+ margin: 0 0 1.5rem;
+ padding: 0.25rem 0.75rem;
+
+ border: 1px solid var(--color-border);
+ border-radius: 0.5rem;
+}
+
+.unit-toggle legend {
+ padding: 0 0.25rem;
+ font-size: 0.85rem;
+}
+
+.unit-toggle label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ cursor: pointer;
+}
diff --git a/frontend/src/utils/units.test.ts b/frontend/src/utils/units.test.ts
new file mode 100644
index 0000000..95b1569
--- /dev/null
+++ b/frontend/src/utils/units.test.ts
@@ -0,0 +1,44 @@
+import { expect, test } from 'vitest'
+import { convertText } from './units'
+
+const metric = (text: string) => convertText(text, 'metric')
+
+test('leaves text untouched in US mode', () => {
+ expect(convertText('1 cup flour', 'us')).toBe('1 cup flour')
+})
+
+test('converts cups and rounds to kitchen values', () => {
+ expect(metric('1 cup all-purpose flour')).toBe('240 ml all-purpose flour')
+ expect(metric('1/4 cup soy sauce')).toBe('60 ml soy sauce')
+ expect(metric('1 1/2 cups milk')).toBe('350 ml milk')
+ expect(metric('5 cups water')).toBe('1.2 L water')
+})
+
+test('converts ounces, fluid ounces and pounds', () => {
+ expect(metric('8 oz penne pasta')).toBe('230 g penne pasta')
+ expect(metric('2 fl oz rum')).toBe('60 ml rum')
+ expect(metric('1 1/2 lb pork tenderloin')).toBe('680 g pork tenderloin')
+ expect(metric('3 pounds beef chuck')).toBe('1.4 kg beef chuck')
+})
+
+test('handles unicode fractions and ranges', () => {
+ expect(metric('½ cup sugar')).toBe('120 ml sugar')
+ expect(metric('2¾ cups stock')).toBe('650 ml stock')
+ expect(metric('a 3½–4-lb. chicken')).toBe('a 1.6–1.8-kg. chicken')
+})
+
+test('converts inches and keeps the hyphenated form', () => {
+ expect(metric('Cut into 1-inch pieces')).toBe('Cut into 2.5-cm pieces')
+ expect(metric('a 9 inch pan')).toBe('a 23 cm pan')
+})
+
+test('converts temperatures inside directions', () => {
+ expect(metric('Heat the oven to 425°F.')).toBe('Heat the oven to 220°C.')
+ expect(metric('until it reads 145°F')).toBe('until it reads 63°C')
+})
+
+test('leaves unrecognised text and tsp/tbsp unchanged', () => {
+ expect(metric('2 tablespoons olive oil')).toBe('2 tablespoons olive oil')
+ expect(metric('Salt and pepper to taste')).toBe('Salt and pepper to taste')
+ expect(metric('3 garlic cloves')).toBe('3 garlic cloves')
+})
diff --git a/frontend/src/utils/units.ts b/frontend/src/utils/units.ts
new file mode 100644
index 0000000..ce645fb
--- /dev/null
+++ b/frontend/src/utils/units.ts
@@ -0,0 +1,120 @@
+export type UnitSystem = 'us' | 'metric'
+
+const UNICODE_FRACTIONS: Record
= {
+ '½': 0.5,
+ '⅓': 1 / 3,
+ '⅔': 2 / 3,
+ '¼': 0.25,
+ '¾': 0.75,
+ '⅛': 0.125,
+}
+
+// Matches "1 1/2", "1/2", "2¾", "½" and "1.5". Mixed numbers come first so
+// "1 1/2" is not read as "1" followed by "1/2".
+const NUMBER = String.raw`\d+\s+\d+/\d+|\d+/\d+|\d*[½⅓⅔¼¾⅛]|\d+(?:\.\d+)?`
+const RANGE = String.raw`(${NUMBER})(?:\s*(?:–|-|to)\s*(${NUMBER}))?`
+// Longer unit names first so "fluid ounces" is not cut short at "ounces".
+const UNIT = String.raw`fluid ounces?|fl\.? ?oz|cups?|ounces?|oz|pounds?|lbs?|inch(?:es)?`
+const QUANTITY = new RegExp(
+ String.raw`(? {
+ const [top, bottom] = part.split('/')
+ return sum + (bottom ? Number(top) / Number(bottom) : Number(top))
+ }, 0)
+}
+
+const nearest = (value: number, step: number) => Math.round(value / step) * step
+// Drops float noise and trailing zeros: 1.2000000000000002 -> "1.2".
+const format = (value: number) => String(Number(value.toFixed(2)))
+
+type Metric = { value: string; unit: string }
+
+// Kitchen-friendly rounding: 236.6 ml -> 240 ml, 59 ml -> 60 ml.
+function roundVolume(ml: number): Metric {
+ if (ml >= 1000) return { value: format(nearest(ml / 1000, 0.1)), unit: 'L' }
+ return {
+ value: format(ml < 100 ? nearest(ml, 5) : nearest(ml, 10)),
+ unit: 'ml',
+ }
+}
+
+function roundWeight(g: number): Metric {
+ if (g >= 1000) return { value: format(nearest(g / 1000, 0.1)), unit: 'kg' }
+ return { value: format(g < 100 ? nearest(g, 5) : nearest(g, 10)), unit: 'g' }
+}
+
+function roundLength(cm: number): Metric {
+ return {
+ value: format(cm < 10 ? nearest(cm, 0.5) : Math.round(cm)),
+ unit: 'cm',
+ }
+}
+
+function toMetric(amount: number, usUnit: string): Metric {
+ const u = usUnit.toLowerCase()
+ if (u.startsWith('cup')) return roundVolume(amount * 236.588)
+ if (u.startsWith('fl') || u.startsWith('fluid'))
+ return roundVolume(amount * 29.5735)
+ if (u.startsWith('oz') || u.startsWith('ounce'))
+ return roundWeight(amount * 28.3495)
+ if (u.startsWith('lb') || u.startsWith('pound'))
+ return roundWeight(amount * 453.592)
+ return roundLength(amount * 2.54)
+}
+
+function fahrenheitToCelsius(f: number): string {
+ const c = ((f - 32) * 5) / 9
+ // Ovens move in 5-degree steps; low temperatures (thermometer) stay exact.
+ return `${c >= 100 ? nearest(c, 5) : Math.round(c)}°C`
+}
+
+// Converts US quantities and temperatures in a free-text ingredient line or
+// direction. Text that is not recognised is returned unchanged, so the
+// original dataset wording is never lost. tsp/tbsp are left alone on purpose.
+export function convertText(text: string, system: UnitSystem): string {
+ if (system === 'us') return text
+
+ return text
+ .replace(
+ QUANTITY,
+ (
+ _match,
+ from: string,
+ to: string | undefined,
+ sep: string,
+ unit: string,
+ ) => {
+ const first = toMetric(parseNumber(from), unit)
+ // Keep a hyphen ("2.5-cm pieces") but normalise other gaps to a space.
+ const joiner = sep.includes('-') ? '-' : ' '
+ if (!to) return `${first.value}${joiner}${first.unit}`
+
+ // Both ends of a range share one unit, e.g. "1.6–1.8 kg".
+ const second = toMetric(parseNumber(to), unit)
+ return first.unit === second.unit
+ ? `${first.value}–${second.value}${joiner}${second.unit}`
+ : `${first.value} ${first.unit}–${second.value} ${second.unit}`
+ },
+ )
+ .replace(TEMPERATURE, (_match, f: string) =>
+ fahrenheitToCelsius(parseNumber(f)),
+ )
+}
diff --git a/frontend/src/utils/useUnitSystem.ts b/frontend/src/utils/useUnitSystem.ts
new file mode 100644
index 0000000..6b106cf
--- /dev/null
+++ b/frontend/src/utils/useUnitSystem.ts
@@ -0,0 +1,38 @@
+import { useSyncExternalStore } from 'react'
+import type { UnitSystem } from './units'
+
+const STORAGE_KEY = 'unit-system'
+const listeners = new Set<() => void>()
+
+function read(): UnitSystem {
+ try {
+ return localStorage.getItem(STORAGE_KEY) === 'metric' ? 'metric' : 'us'
+ } catch {
+ // Storage can be blocked (private mode); fall back to the dataset's units.
+ return 'us'
+ }
+}
+
+function write(system: UnitSystem) {
+ try {
+ localStorage.setItem(STORAGE_KEY, system)
+ } catch {
+ // Not remembered across reloads, but the choice still applies this visit.
+ }
+ listeners.forEach((listener) => listener())
+}
+
+// A tiny external store instead of a context provider: every component that
+// shows quantities stays in sync without wrapping the app, and the choice
+// survives page loads via localStorage.
+export function useUnitSystem(): [UnitSystem, (system: UnitSystem) => void] {
+ const system = useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+ },
+ read,
+ (): UnitSystem => 'us',
+ )
+ return [system, write]
+}