Add Phase 1 Pocket Pascal UI: home, pantry, week plan, and plate.

Replace the hello-world shell with the elevated design system, bilingual screens, recipe/extras data, and the interactive Mein Teller view.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Steffi Müller
2026-08-04 12:22:32 +02:00
parent 3785d08e97
commit a482bf5b09
60 changed files with 7366 additions and 72 deletions

View File

@@ -1,66 +1,30 @@
import { useCallback, useEffect, useState } from "react"
import { RefreshCw } from "lucide-react"
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { fetchHello, type HelloResponse } from "@/lib/api"
import { HomeScreen } from "@/components/home/HomeScreen"
import { AppShell } from "@/components/layout/AppShell"
import { LanguageProvider } from "@/lib/language"
import { BuilderScreen } from "@/screens/BuilderScreen"
import { PantryScreen } from "@/screens/PantryScreen"
import { PlateScreen } from "@/screens/PlateScreen"
import { SettingsScreen } from "@/screens/SettingsScreen"
import { WeekPlanScreen } from "@/screens/WeekPlanScreen"
export default function App() {
const [data, setData] = useState<HelloResponse | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
setData(await fetchHello())
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void load()
}, [load])
return (
<main className="dark:bg-background dark:text-foreground flex min-h-svh flex-col items-center justify-center bg-background gap-6 p-6 text-center">
<div className="flex max-w-sm flex-col items-center gap-6">
<span className="bg-primary/10 text-primary rounded-full px-3 py-1 text-xs font-medium">
Pocket Pascal
</span>
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
{data?.message ?? "Hello, World!"}
</h1>
<p className="text-muted-foreground text-sm">
A minimal installable PWA. React + shadcn/ui on the front, Express +
SQLite on the back.
</p>
<div className="bg-muted text-muted-foreground flex w-full flex-col gap-1 rounded-lg border p-4 text-sm">
{loading ? (
<span>Asking the server</span>
) : error ? (
<span className="text-destructive">{error}</span>
) : (
<>
<span className="text-foreground text-2xl font-semibold">
{data?.visits.toLocaleString()}
</span>
<span>times this hello has been said</span>
</>
)}
</div>
<Button onClick={() => void load()} disabled={loading}>
<RefreshCw className={loading ? "animate-spin" : undefined} />
Say it again
</Button>
</div>
</main>
<LanguageProvider>
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<HomeScreen />} />
<Route path="pantry" element={<PantryScreen />} />
<Route path="knowledge" element={<PlateScreen />} />
<Route path="weekplan" element={<WeekPlanScreen />} />
<Route path="builder" element={<BuilderScreen />} />
<Route path="settings" element={<SettingsScreen />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</LanguageProvider>
)
}

View File

@@ -0,0 +1,43 @@
import { useNavigate } from "react-router-dom"
import { ChefHat } from "lucide-react"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
/** Navigation tile — whole card is the hit target (same pattern as KnowledgeHub). */
export function BuilderSlot() {
const { lang } = useLanguage()
const navigate = useNavigate()
return (
<GlassCard
variant="interactive"
className="p-4"
onClick={() => navigate("/builder")}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
navigate("/builder")
}
}}
>
<div className="flex flex-col gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
style={{ backgroundImage: "var(--gradient-primary)" }}
>
<ChefHat className="h-5 w-5 text-[var(--ink)]" aria-hidden />
</span>
<div>
<h3 className="type-title-sm text-[var(--ink)]">
{t(ui.builderSlot.title, lang)}
</h3>
<p className="type-body mt-1 text-[var(--ink-soft)]">
{t(ui.builderSlot.body, lang)}
</p>
</div>
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,133 @@
import { useEffect, useState, type ReactNode } from "react"
import { ChevronDown, ChevronUp } from "lucide-react"
import { BuilderSlot } from "@/components/home/BuilderSlot"
import { KnowledgeHub } from "@/components/home/KnowledgeHub"
import { SuggestionCard } from "@/components/home/SuggestionCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import {
DEFAULT_WIDGET_ORDER,
moveWidget,
readWidgetOrder,
writeWidgetOrder,
} from "@/lib/widgetOrder"
import type { HomeWidgetId, WidgetOrder } from "@/types/domain"
function WidgetShell({
id,
editing,
order,
onMove,
children,
}: {
id: HomeWidgetId
editing: boolean
order: WidgetOrder
onMove: (id: HomeWidgetId, direction: "up" | "down") => void
children: ReactNode
}) {
const { lang } = useLanguage()
const index = order.indexOf(id)
return (
<section className="relative">
{editing ? (
<div className="mb-2 flex items-center justify-end gap-1">
<button
type="button"
aria-label={t(ui.home.moveUp, lang)}
disabled={index <= 0}
onClick={() => onMove(id, "up")}
className={cx(
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
"disabled:opacity-30",
)}
>
<ChevronUp className="h-5 w-5" />
</button>
<button
type="button"
aria-label={t(ui.home.moveDown, lang)}
disabled={index >= order.length - 1}
onClick={() => onMove(id, "down")}
className={cx(
"inline-flex h-12 w-12 items-center justify-center rounded-[var(--radius-pill)]",
"bg-white/80 text-[var(--ink)] ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
"disabled:opacity-30",
)}
>
<ChevronDown className="h-5 w-5" />
</button>
</div>
) : null}
{children}
</section>
)
}
export function HomeScreen() {
const { lang } = useLanguage()
const [editing, setEditing] = useState(false)
const [order, setOrder] = useState<WidgetOrder>(DEFAULT_WIDGET_ORDER)
useEffect(() => {
setOrder(readWidgetOrder())
}, [])
function handleMove(id: HomeWidgetId, direction: "up" | "down") {
setOrder((current) => {
const next = moveWidget(current, id, direction)
writeWidgetOrder(next)
return next
})
}
const widgets: Record<HomeWidgetId, ReactNode> = {
suggestion: <SuggestionCard />,
knowledge: <KnowledgeHub />,
builder: <BuilderSlot />,
}
return (
<div className="flex flex-col gap-4">
<header className="flex items-start justify-between gap-3">
<div>
<p className="type-label text-[var(--ink-faint)]">Pocket Pascal</p>
<h1 className="type-display mt-1 text-[var(--ink)]">
{t(ui.home.title, lang)}
</h1>
</div>
<button
type="button"
onClick={() => setEditing((value) => !value)}
className={cx(
"elevation-transition inline-flex min-h-[48px] items-center rounded-[var(--radius-pill)] px-4",
"type-button text-[var(--ink)]",
"ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
)}
style={{ background: "var(--glass-fill)" }}
>
{editing ? t(ui.home.doneEdit, lang) : t(ui.home.editLayout, lang)}
</button>
</header>
{order.map((id) => (
<WidgetShell
key={id}
id={id}
editing={editing}
order={order}
onMove={handleMove}
>
{widgets[id]}
</WidgetShell>
))}
</div>
)
}

View File

@@ -0,0 +1,79 @@
import { useNavigate } from "react-router-dom"
import { BookOpen, CalendarDays, Refrigerator } from "lucide-react"
import type { ReactNode } from "react"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
function HubTile({
title,
body,
gradient,
icon,
onNavigate,
}: {
title: string
body: string
gradient: string
icon: ReactNode
onNavigate: () => void
}) {
return (
<GlassCard
variant="interactive"
className="p-4"
onClick={onNavigate}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onNavigate()
}
}}
>
<div className="flex flex-col gap-3">
<span
className="flex h-10 w-10 items-center justify-center rounded-[var(--radius-control)]"
style={{ backgroundImage: gradient }}
>
{icon}
</span>
<div>
<h3 className="type-title-sm text-[var(--ink)]">{title}</h3>
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
</div>
</div>
</GlassCard>
)
}
export function KnowledgeHub() {
const { lang } = useLanguage()
const navigate = useNavigate()
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<HubTile
title={t(ui.knowledgeHub.plateTitle, lang)}
body={t(ui.knowledgeHub.plateBody, lang)}
gradient="var(--gradient-primary)"
icon={<BookOpen className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge")}
/>
<HubTile
title={t(ui.knowledgeHub.pantryTitle, lang)}
body={t(ui.knowledgeHub.pantryBody, lang)}
gradient="var(--gradient-accent)"
icon={<Refrigerator className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/pantry")}
/>
<HubTile
title={t(ui.knowledgeHub.weekplanTitle, lang)}
body={t(ui.knowledgeHub.weekplanBody, lang)}
gradient="var(--gradient-primary)"
icon={<CalendarDays className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/weekplan")}
/>
</div>
)
}

View File

@@ -0,0 +1,128 @@
import { useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { Pin, RefreshCw } from "lucide-react"
import { Button } from "@/components/ui-pp/Button"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, tr, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import { buildSuggestion, reshuffleSuggestion } from "@/lib/suggestion"
import { formatClock, getTimeContext } from "@/lib/timeContext"
import type { FoodItem, Recipe, TimeSlot } from "@/types/domain"
const CAT_DOT: Record<string, string> = {
protein: "var(--color-protein)",
obst: "var(--color-obstgem)",
gemuese: "var(--color-obstgem)",
fett: "var(--color-fett)",
kh: "var(--color-kh)",
snack: "var(--color-protein)",
}
function slotLabel(slot: TimeSlot, lang: "de" | "en"): string {
return t(ui.suggestion[slot], lang)
}
export function SuggestionCard() {
const { lang } = useLanguage()
const navigate = useNavigate()
const context = useMemo(() => getTimeContext(), [])
const [suggestion, setSuggestion] = useState(() => buildSuggestion(context.slot))
const [pinnedIds, setPinnedIds] = useState<Set<string>>(() => new Set())
const recipe: Recipe | null = suggestion.recipe
const items: FoodItem[] = suggestion.foods
const clock = formatClock(context.hour, context.minute, lang)
const header =
lang === "de"
? `${clock} ${t(ui.suggestion.timeSuffix, lang)} · ${slotLabel(context.slot, lang)}`
: `${clock} · ${slotLabel(context.slot, lang)}`
const title = recipe ? tr(recipe.name, lang) : t(ui.suggestion.fallbackName, lang)
function togglePin(id: string) {
setPinnedIds((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function reroll() {
if (pinnedIds.size === 0) {
setSuggestion(buildSuggestion(context.slot, recipe?.id))
return
}
setSuggestion({
recipe: null,
foods: reshuffleSuggestion(items, pinnedIds, context.slot),
})
}
function sendToBuilder() {
navigate("/builder", {
state: {
ingredientIds: items.map((item) => item.id),
source: "suggestion" as const,
},
})
}
return (
<GlassCard className="p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="type-label text-[var(--ink-faint)]">{header}</p>
<h2 className="type-title text-[var(--ink)]">{title}</h2>
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => {
const pinned = pinnedIds.has(item.id)
return (
<button
key={item.id}
type="button"
onClick={() => togglePin(item.id)}
aria-pressed={pinned}
aria-label={`${tr(item.name, lang)}${pinned ? t(ui.suggestion.unpin, lang) : t(ui.suggestion.pin, lang)}`}
className={cx(
"elevation-transition inline-flex min-h-[48px] items-center gap-2 rounded-[var(--radius-pill)] px-3",
"type-body-emphasis text-[var(--ink)]",
"ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
pinned && "ring-2 ring-[var(--color-pick)]/50",
)}
style={{ background: "var(--glass-fill)" }}
>
<span
aria-hidden
className="h-2.5 w-2.5 rounded-full"
style={{ background: CAT_DOT[item.cat] ?? "var(--ink-faint)" }}
/>
<span>{tr(item.name, lang)}</span>
{pinned ? (
<Pin className="h-3.5 w-3.5 text-[var(--color-pick)]" aria-hidden />
) : null}
</button>
)
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Button variant="secondary" className="flex-1" onClick={reroll}>
<RefreshCw className="h-4 w-4" aria-hidden />
{t(ui.suggestion.reroll, lang)}
</Button>
<Button className="flex-1" onClick={sendToBuilder}>
{t(ui.suggestion.toBuilder, lang)}
</Button>
</div>
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,53 @@
import { Outlet, useLocation, useNavigate } from "react-router-dom"
import { BookOpen, Home, Settings2, ShoppingBasket } from "lucide-react"
import { BottomNav } from "@/components/ui-pp/BottomNav"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
const ROUTES = [
{ key: "home", path: "/", icon: Home, labelKey: "home" as const },
{ key: "pantry", path: "/pantry", icon: ShoppingBasket, labelKey: "pantry" as const },
{ key: "knowledge", path: "/knowledge", icon: BookOpen, labelKey: "knowledge" as const },
{ key: "settings", path: "/settings", icon: Settings2, labelKey: "settings" as const },
]
export function AppShell() {
const { lang } = useLanguage()
const location = useLocation()
const navigate = useNavigate()
const items = ROUTES.map((route) => {
const Icon = route.icon
const active =
route.path === "/"
? location.pathname === "/"
: location.pathname.startsWith(route.path)
return {
key: route.key,
label: t(ui.nav[route.labelKey], lang),
active,
icon: <Icon className="h-5 w-5" strokeWidth={active ? 2.4 : 2} />,
onClick: () => navigate(route.path),
}
})
return (
<div
className="min-h-svh text-[var(--ink)]"
style={{ backgroundImage: "var(--page-gradient)" }}
>
<main
className="mx-auto w-full max-w-lg px-4"
style={{
paddingTop: "calc(env(safe-area-inset-top) + 16px)",
paddingBottom: "calc(env(safe-area-inset-bottom) + 108px)",
}}
>
<Outlet />
</main>
<BottomNav items={items} />
</div>
)
}

View File

@@ -0,0 +1,182 @@
import { useId } from "react"
import { cx } from "@/lib/cx"
export interface PlateSegment {
id: string
/** Share of the plate (relative weights; need not sum to 1). */
weight: number
color: string
label: string
}
export interface DiningPlateProps {
segments: PlateSegment[]
size?: number
onSelect?: (id: string) => void
className?: string
"aria-label"?: string
}
function polar(centerX: number, centerY: number, r: number, angleDeg: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180
return { x: centerX + r * Math.cos(rad), y: centerY + r * Math.sin(rad) }
}
/** Filled pie wedge from center (food on the plate — not a thin outer ring). */
function wedgePath(
centerX: number,
centerY: number,
r: number,
startAngle: number,
endAngle: number,
): string {
if (endAngle - startAngle >= 359.9) {
return [
`M ${centerX} ${centerY - r}`,
`A ${r} ${r} 0 1 1 ${centerX} ${centerY + r}`,
`A ${r} ${r} 0 1 1 ${centerX} ${centerY - r}`,
"Z",
].join(" ")
}
const start = polar(centerX, centerY, r, startAngle)
const end = polar(centerX, centerY, r, endAngle)
const large = endAngle - startAngle > 180 ? 1 : 0
return [
`M ${centerX} ${centerY}`,
`L ${start.x} ${start.y}`,
`A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`,
"Z",
].join(" ")
}
/**
* Ceramic dining plate with food-like category wedges inside the rim.
* Rim depth via CSS inset shadows; wedges are SVG (clickable).
*/
export function DiningPlate({
segments,
size = 280,
onSelect,
className,
"aria-label": ariaLabel,
}: DiningPlateProps) {
const uid = useId().replace(/:/g, "")
const centerX = size / 2
const centerY = size / 2
const rimPad = size * 0.09
const foodR = size / 2 - rimPad
const gapDeg = 2.5
const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1
let angle = 0
const wedges = segments.map((segment) => {
const sweep = (segment.weight / total) * 360
const start = angle + gapDeg / 2
const end = angle + sweep - gapDeg / 2
angle += sweep
return {
...segment,
start,
end,
d: end > start ? wedgePath(centerX, centerY, foodR, start, end) : null,
}
})
return (
<div
className={cx("relative select-none", className)}
style={{ width: size, height: size }}
>
{/* Ceramic body + raised rim */}
<div
className="absolute inset-0 rounded-full"
style={{
background:
"radial-gradient(circle at 38% 32%, #ffffff 0%, var(--plate-well-center) 45%, var(--plate-well-edge) 100%)",
boxShadow: `
0 8px 24px rgba(25, 24, 0, 0.12),
inset 4px 5px 10px rgba(255, 255, 255, 0.85),
inset -5px -6px 12px rgba(25, 24, 0, 0.12),
inset 0 0 0 1px rgba(25, 24, 0, 0.06)
`,
}}
/>
{/* Soft recessed well under the food */}
<div
className="absolute rounded-full"
style={{
inset: rimPad * 0.55,
background:
"radial-gradient(circle at 50% 45%, #fafaf6 0%, #ebebe3 100%)",
boxShadow: "inset 0 2px 8px rgba(25, 24, 0, 0.08)",
}}
/>
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={ariaLabel}
className="relative z-10"
>
<defs>
<filter id={`${uid}-food-soft`} x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="1.2" result="blur" />
<feOffset dy="1" result="off" />
<feComponentTransfer>
<feFuncA type="linear" slope="0.18" />
</feComponentTransfer>
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<g filter={`url(#${uid}-food-soft)`}>
{wedges.map(
(wedge) =>
wedge.d && (
<path
key={wedge.id}
d={wedge.d}
fill={wedge.color}
opacity={0.92}
className={cx(
"outline-none transition-[opacity,filter] duration-150",
onSelect &&
"cursor-pointer hover:opacity-100 focus-visible:opacity-100",
)}
tabIndex={onSelect ? 0 : undefined}
role={onSelect ? "button" : undefined}
aria-label={wedge.label}
onClick={() => onSelect?.(wedge.id)}
onKeyDown={(event) => {
if (!onSelect) return
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onSelect(wedge.id)
}
}}
/>
),
)}
</g>
{/* Subtle inner rim line separating food from ceramic lip */}
<circle
cx={centerX}
cy={centerY}
r={foodR + 1}
fill="none"
stroke="rgba(255,255,255,0.55)"
strokeWidth={2}
pointerEvents="none"
/>
</svg>
</div>
)
}

View File

@@ -0,0 +1,168 @@
import { useId, useState, type ReactNode } from "react"
import { ChevronRight } from "lucide-react"
import { cx } from "@/lib/cx"
export interface AccordionProps {
title: ReactNode
meta?: ReactNode
icon?: ReactNode
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
className?: string
children: ReactNode
}
/**
* Top-level elevated accordion (type groups, pantry categories).
* Bevel/fill use rounded-[inherit] so radius tokens stay consistent.
*/
export function Accordion({
title,
meta,
icon,
defaultOpen = false,
open: controlledOpen,
onOpenChange,
className,
children,
}: AccordionProps) {
const reactId = useId()
const panelId = `acc-panel-${reactId}`
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
function toggle() {
const next = !open
if (!isControlled) setUncontrolledOpen(next)
onOpenChange?.(next)
}
return (
<div
className={cx(
"overflow-hidden rounded-[var(--radius-card)] shadow-[var(--shadow-elevated)]",
className,
)}
style={{ backgroundImage: "var(--surface-gradient)" }}
>
<div className="relative">
<div
className="absolute inset-0 rounded-[inherit]"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={toggle}
className={cx(
"relative z-10 flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left",
"font-body no-select [-webkit-tap-highlight-color:transparent]",
"active:brightness-[0.98]",
)}
>
{icon ? (
<span className="shrink-0 text-xl leading-none">{icon}</span>
) : null}
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
{title}
</span>
{meta ? (
<span className="type-caption shrink-0 font-bold text-[var(--ink-faint)]">
{meta}
</span>
) : null}
<ChevronRight
aria-hidden
className={cx(
"h-5 w-5 shrink-0 text-[var(--ink-faint)] transition-transform duration-150",
open && "rotate-90",
)}
/>
</button>
{open ? (
<div
id={panelId}
className="relative z-10 border-t border-[var(--border)] px-3 py-3"
>
{children}
</div>
) : null}
</div>
</div>
)
}
export interface NestedDisclosureProps {
title: ReactNode
defaultOpen?: boolean
className?: string
children: ReactNode
}
/**
* Compact nested row for lists inside an Accordion.
* Radius --radius-nested with parent p-3 → outer card 24 = 12 + 12.
*/
export function NestedDisclosure({
title,
defaultOpen = false,
className,
children,
}: NestedDisclosureProps) {
const reactId = useId()
const panelId = `nested-panel-${reactId}`
const [open, setOpen] = useState(defaultOpen)
return (
<div
className={cx(
"overflow-hidden rounded-[var(--radius-nested)]",
"ring-1 ring-[var(--border)]",
className,
)}
style={{ background: "var(--glass-fill)" }}
>
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
className={cx(
"flex min-h-[48px] w-full items-center gap-2 px-3 py-2 text-left",
"font-body no-select [-webkit-tap-highlight-color:transparent]",
"active:brightness-[0.98]",
)}
>
<span className="type-body-emphasis min-w-0 flex-1 text-[var(--ink)]">
{title}
</span>
<ChevronRight
aria-hidden
className={cx(
"h-4 w-4 shrink-0 text-[var(--ink-faint)] transition-transform duration-150",
open && "rotate-90",
)}
/>
</button>
{open ? (
<div
id={panelId}
className="border-t border-[var(--border)] px-3 py-3"
>
{children}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,98 @@
import type { ReactNode } from "react"
import { cx } from "@/lib/cx"
export interface NavItem {
key: string
icon: ReactNode
label: string
active?: boolean
onClick?: () => void
href?: string
}
export interface BottomNavProps {
items: NavItem[]
className?: string
}
/**
* Floating pill bottom navigation — float elevation above content cards.
*/
export function BottomNav({ items, className }: BottomNavProps) {
return (
<nav
className={cx(
"pointer-events-none fixed inset-x-0 bottom-0 z-50 flex justify-center",
className,
)}
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) + 12px)" }}
>
<div
className="pointer-events-auto relative flex items-center gap-1 rounded-[var(--radius-pill)] px-2 py-2"
style={{ boxShadow: "var(--shadow-float)" }}
>
<div
className="absolute inset-0 rounded-[inherit]"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
{items.map((item) => {
const content = (
<>
<span
aria-hidden
className={cx(
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
item.active
? "text-[var(--color-obstgem-dk)]"
: "text-[var(--ink-soft)]",
)}
>
{item.icon}
</span>
<span
className={cx(
"type-label normal-case tracking-wide",
item.active
? "bg-clip-text text-transparent"
: "text-[var(--ink-soft)]",
)}
style={
item.active
? { backgroundImage: "var(--gradient-accent)" }
: undefined
}
>
{item.label}
</span>
</>
)
const itemClass = cx(
"relative z-10 flex min-h-[48px] min-w-[48px] flex-col items-center justify-center gap-1",
"elevation-transition no-select rounded-[var(--radius-pill)] px-3",
"active:translate-y-px active:brightness-95",
)
return item.href ? (
<a key={item.key} href={item.href} className={itemClass}>
{content}
</a>
) : (
<button
key={item.key}
type="button"
onClick={item.onClick}
className={itemClass}
>
{content}
</button>
)
})}
</div>
</nav>
)
}

View File

@@ -0,0 +1,115 @@
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"
import { cx } from "@/lib/cx"
export type ButtonVariant = "primary" | "secondary" | "fab"
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
loading?: boolean
/** Required for `variant="fab"` — icon-only, no visible label. */
icon?: ReactNode
}
function Spinner({ dark = false }: { dark?: boolean }) {
return (
<span
aria-hidden
className={cx(
"inline-block h-4 w-4 animate-spin rounded-full border-2",
dark
? "border-[var(--ink)]/25 border-t-[var(--ink)]"
: "border-[var(--ink)]/25 border-t-[var(--ink)]",
)}
/>
)
}
const pressable = cx(
"elevation-transition active:translate-y-px active:brightness-95",
"shadow-[var(--shadow-elevated)] active:shadow-[var(--shadow-pressed)]",
)
const base = cx(
"relative inline-flex items-center justify-center gap-2",
"type-button no-select [-webkit-tap-highlight-color:transparent]",
"disabled:opacity-40 disabled:pointer-events-none disabled:active:translate-y-0 disabled:active:shadow-[var(--shadow-elevated)]",
)
/**
* Touch-optimized button set. All variants meet the 48px minimum touch target.
* Text on gradients uses --ink for WCAG AA on light brand hues.
*/
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{ variant = "primary", loading = false, disabled, icon, className, children, ...props },
ref,
) => {
const isDisabled = disabled || loading
if (variant === "fab") {
return (
<button
ref={ref}
disabled={isDisabled}
className={cx(
base,
pressable,
"h-14 w-14 rounded-[var(--radius-pill)] text-[var(--ink)]",
className,
)}
style={{ backgroundImage: "var(--gradient-accent)" }}
{...props}
>
{loading ? <Spinner dark /> : (icon ?? children)}
</button>
)
}
if (variant === "secondary") {
return (
<button
ref={ref}
disabled={isDisabled}
className={cx(
base,
pressable,
"type-button-secondary min-h-[48px] overflow-hidden rounded-[var(--radius-control)] px-6 text-[var(--ink)]",
className,
)}
{...props}
>
<span
className="absolute inset-0 rounded-[inherit]"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
<span className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
<span className="relative z-10 flex items-center gap-2">
{loading ? <Spinner dark /> : children}
</span>
</button>
)
}
return (
<button
ref={ref}
disabled={isDisabled}
className={cx(
base,
pressable,
"min-h-[48px] rounded-[var(--radius-control)] px-6 text-[var(--ink)]",
className,
)}
style={{ backgroundImage: "var(--gradient-primary)" }}
{...props}
>
{loading ? <Spinner dark /> : children}
</button>
)
},
)
Button.displayName = "Button"

View File

@@ -0,0 +1,48 @@
import { forwardRef, type HTMLAttributes } from "react"
import { cx } from "@/lib/cx"
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
/**
* `default` — static, sits at rest elevation.
* `interactive` — settles down 1px + softens shadow on press.
*/
variant?: "default" | "interactive"
}
/**
* Elevated Surface Card — primary container.
* Radius via --radius-card; bevel inherits so nested overrides stay consistent.
*/
export const GlassCard = forwardRef<HTMLDivElement, GlassCardProps>(
({ variant = "default", className, children, ...props }, ref) => {
const interactive = variant === "interactive"
return (
<div
ref={ref}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
className={cx(
"elevation-transition relative no-select rounded-[var(--radius-card)]",
"shadow-[var(--shadow-elevated)]",
interactive &&
"cursor-pointer active:translate-y-px active:shadow-[var(--shadow-resting)]",
className,
)}
style={{ backgroundImage: "var(--surface-gradient)" }}
{...props}
>
<div
className="absolute inset-0 rounded-[inherit]"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
<div className="relative z-10">{children}</div>
</div>
)
},
)
GlassCard.displayName = "GlassCard"

View File

@@ -0,0 +1,62 @@
import { cx } from "@/lib/cx"
export interface SegOption<T extends string> {
value: T
label: string
}
export interface SegControlProps<T extends string> {
value: T
options: SegOption<T>[]
onChange: (value: T) => void
className?: string
/** Accessible name for the control group. */
"aria-label"?: string
}
/**
* Segmented control — same interaction pattern as the prototype `.segctl`.
* Soft track + elevated active pill. Touch targets ≥48px.
*/
export function SegControl<T extends string>({
value,
options,
onChange,
className,
"aria-label": ariaLabel,
}: SegControlProps<T>) {
return (
<div
role="tablist"
aria-label={ariaLabel}
className={cx(
"flex rounded-[var(--radius-pill)] p-1",
className,
)}
style={{ background: "var(--green-soft)" }}
>
{options.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
type="button"
role="tab"
aria-selected={active}
onClick={() => onChange(option.value)}
className={cx(
"type-button-secondary flex-1 min-h-[48px] rounded-[var(--radius-pill)] px-3",
"no-select [-webkit-tap-highlight-color:transparent]",
"elevation-transition",
active
? "bg-[var(--glass-fill)] text-[var(--ink)] shadow-[var(--shadow-resting)]"
: "text-[var(--ink-soft)] active:brightness-95",
)}
>
{option.label}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,14 @@
[
{ "id": "soy_sauce", "name": { "de": "Sojasauce", "en": "Soy sauce" } },
{ "id": "lime", "name": { "de": "Limette", "en": "Lime" } },
{ "id": "garlic", "name": { "de": "Knoblauch", "en": "Garlic" } },
{ "id": "cocoa_powder", "name": { "de": "Kakaopulver", "en": "Cocoa powder" } },
{ "id": "honey", "name": { "de": "Honig", "en": "Honey" } },
{ "id": "dates", "name": { "de": "Datteln", "en": "Dates" } },
{ "id": "ginger", "name": { "de": "Ingwer", "en": "Ginger" } },
{ "id": "sesame_oil", "name": { "de": "Sesamöl", "en": "Sesame oil" } },
{ "id": "lemon", "name": { "de": "Zitrone", "en": "Lemon" } },
{ "id": "oregano", "name": { "de": "Oregano", "en": "Oregano" } },
{ "id": "cumin", "name": { "de": "Kreuzkümmel", "en": "Cumin" } },
{ "id": "paprika_powder", "name": { "de": "Paprikapulver", "en": "Paprika powder" } }
]

979
client/src/data/foods.json Normal file
View File

@@ -0,0 +1,979 @@
[
{
"id": "protein",
"cls": "protein",
"ic": "🥚",
"title": {
"de": "Protein",
"en": "Protein"
},
"thumb": {
"de": "Mindestens 1520 g Protein pro 100 g, wenig Zucker & wenig Fett, kurze Zutatenliste.",
"en": "At least 1520 g protein per 100 g, low sugar & low fat, short ingredient list."
},
"items": [
{
"id": "edamame",
"name": {
"de": "Edamame",
"en": "Edamame"
},
"v": {
"de": "125 kcal · 14 g",
"en": "125 kcal · 14 g"
}
},
{
"id": "egg",
"name": {
"de": "Ei (Größe M)",
"en": "Egg (size M)"
},
"v": {
"de": "137 kcal · 13 g",
"en": "137 kcal · 13 g"
}
},
{
"id": "pea_protein_strips",
"name": {
"de": "Erbsenproteinschnetzel",
"en": "Pea-protein strips"
},
"v": {
"de": "367 kcal · 61 g",
"en": "367 kcal · 61 g"
}
},
{
"id": "feta",
"name": {
"de": "Feta (leicht)",
"en": "Feta (light)"
},
"v": {
"de": "161 kcal · 19 g",
"en": "161 kcal · 19 g"
},
"f": "caution"
},
{
"id": "chicken_breast",
"name": {
"de": "Hühnerbrust",
"en": "Chicken breast"
},
"v": {
"de": "111 kcal · 24 g",
"en": "111 kcal · 24 g"
},
"f": "pick"
},
{
"id": "cottage_cheese",
"name": {
"de": "Hüttenkäse",
"en": "Cottage cheese"
},
"v": {
"de": "87 kcal · 12,3 g",
"en": "87 kcal · 12.3 g"
}
},
{
"id": "like_chicken",
"name": {
"de": "„Like Chicken\" (pflanzlich)",
"en": "\"Like Chicken\" (plant-based)"
},
"v": {
"de": "≈140 kcal · 18 g",
"en": "≈140 kcal · 18 g"
},
"approx": true
},
{
"id": "low_fat_quark",
"name": {
"de": "Magerquark",
"en": "Low-fat quark"
},
"v": {
"de": "67 kcal · 12 g",
"en": "67 kcal · 12 g"
}
},
{
"id": "beef_fillet",
"name": {
"de": "Rinderfilet / Tatar",
"en": "Beef fillet / tartare"
},
"v": {
"de": "112 kcal · 22 g",
"en": "112 kcal · 22 g"
},
"f": "pick"
},
{
"id": "silken_tofu",
"name": {
"de": "Seidentofu",
"en": "Silken tofu"
},
"v": {
"de": "≈55 kcal · 5 g",
"en": "≈55 kcal · 5 g"
},
"approx": true
},
{
"id": "skyr",
"name": {
"de": "Skyr",
"en": "Skyr"
},
"v": {
"de": "54 kcal · 10,3 g",
"en": "54 kcal · 10.3 g"
},
"f": "pick"
},
{
"id": "tempeh",
"name": {
"de": "Tempeh",
"en": "Tempeh"
},
"v": {
"de": "184 kcal · 19 g",
"en": "184 kcal · 19 g"
}
},
{
"id": "tofu",
"name": {
"de": "Tofu",
"en": "Tofu"
},
"v": {
"de": "129 kcal · 13 g",
"en": "129 kcal · 13 g"
}
}
],
"sub": {
"title": {
"de": "Milch & Joghurt",
"en": "Milk & yoghurt"
},
"items": [
{
"id": "greek_yogurt_10",
"name": {
"de": "Griech. Joghurt 10 %",
"en": "Greek yoghurt 10%"
},
"v": {
"de": "≈115 kcal · 5 g",
"en": "≈115 kcal · 5 g"
},
"approx": true
},
{
"id": "natural_yogurt",
"name": {
"de": "Naturjoghurt",
"en": "Natural yoghurt"
},
"v": {
"de": "≈61 kcal · 3,5 g",
"en": "≈61 kcal · 3.5 g"
},
"approx": true
},
{
"id": "oatly_barista",
"name": {
"de": "Oatly Barista",
"en": "Oatly Barista"
},
"v": {
"de": "≈68 kcal · 1 g (kaum Protein)",
"en": "≈68 kcal · 1 g (barely protein)"
},
"approx": true,
"f": "caution"
},
{
"id": "whole_milk",
"name": {
"de": "Vollmilch 3,5 %",
"en": "Whole milk 3.5%"
},
"v": {
"de": "≈64 kcal · 3,4 g",
"en": "≈64 kcal · 3.4 g"
},
"approx": true,
"f": "caution"
}
]
}
},
{
"id": "snack",
"cls": "snack",
"ic": "🥤",
"title": {
"de": "Proteinsnacks für Steffi",
"en": "Protein snacks for Steffi"
},
"thumb": {
"de": "Mindestens 15 g Protein pro Portion, unter 5 g Zucker, Fett unter 8 g — Zutatenliste beginnt nicht mit Zucker/Sirup/Schokolade.",
"en": "At least 15 g protein per serving, under 5 g sugar, fat under 8 g — ingredient list shouldnt open with sugar/syrup/chocolate."
},
"items": [
{
"id": "three_eggs",
"name": {
"de": "3 Eier",
"en": "3 eggs"
},
"v": {
"de": "≈205 kcal · 19 g",
"en": "≈205 kcal · 19 g"
},
"approx": true
},
{
"id": "cottage_berries",
"name": {
"de": "Hüttenkäse + Beeren",
"en": "Cottage cheese + berries"
},
"v": {
"de": "≈210 kcal · 25 g",
"en": "≈210 kcal · 25 g"
},
"approx": true
},
{
"id": "chickpea_waffles",
"name": {
"de": "Kichererbsenwaffeln 90g",
"en": "Chickpea waffles 90g"
},
"v": {
"de": "≈300 kcal · 17 g",
"en": "≈300 kcal · 17 g"
},
"approx": true,
"f": "caution"
},
{
"id": "pumpkin_seeds_30",
"name": {
"de": "Kürbiskerne 30g",
"en": "Pumpkin seeds 30g"
},
"v": {
"de": "≈171 kcal · 9 g",
"en": "≈171 kcal · 9 g"
},
"approx": true,
"f": "caution"
},
{
"id": "lentil_waffles",
"name": {
"de": "Linsenwaffeln 90g",
"en": "Lentil waffles 90g"
},
"v": {
"de": "≈300 kcal · 18 g",
"en": "≈300 kcal · 18 g"
},
"approx": true,
"f": "caution"
},
{
"id": "protein_bar",
"name": {
"de": "Magerer Proteinriegel",
"en": "Lean protein bar"
},
"v": {
"de": "≈180 kcal · 20 g",
"en": "≈180 kcal · 20 g"
},
"approx": true
},
{
"id": "protein_rice_pudding",
"name": {
"de": "Proteinmilchreis (2x)",
"en": "Protein rice pudding (2x)"
},
"v": {
"de": "≈340 kcal · 40 g",
"en": "≈340 kcal · 40 g"
},
"approx": true
},
{
"id": "protein_pudding",
"name": {
"de": "Proteinpudding",
"en": "Protein pudding"
},
"v": {
"de": "≈90 kcal · 20 g",
"en": "≈90 kcal · 20 g"
},
"approx": true,
"f": "pick"
},
{
"id": "protein_shake",
"name": {
"de": "Proteinshake 40g",
"en": "Protein shake 40g"
},
"v": {
"de": "≈150 kcal · 30 g",
"en": "≈150 kcal · 30 g"
},
"approx": true,
"f": "pick"
},
{
"id": "turkey_breast_pack",
"name": {
"de": "Putenbrust, 1 Packung",
"en": "Turkey breast, 1 pack"
},
"v": {
"de": "≈130 kcal · 28 g",
"en": "≈130 kcal · 28 g"
},
"approx": true,
"f": "pick"
},
{
"id": "smoked_tofu_cubes",
"name": {
"de": "Räuchertofu-Würfel",
"en": "Smoked tofu cubes"
},
"v": {
"de": "≈225 kcal · 25 g",
"en": "≈225 kcal · 25 g"
},
"approx": true
},
{
"id": "skyr_honey",
"name": {
"de": "Skyr/Griech. Joghurt 10% + Honig, 250g+",
"en": "Skyr/Greek yoghurt 10% + honey, 250g+"
},
"v": {
"de": "≈180 kcal · 25 g",
"en": "≈180 kcal · 25 g"
},
"approx": true
}
],
"recipe": {
"title": {
"de": "Rezept: Whey-Waffeln",
"en": "Recipe: Whey waffles"
},
"text": {
"de": "40 g Whey-Protein + 1 Ei + 40 g Haferflocken (fein gemahlen) + 60 ml Milch/Wasser verquirlen. ≈25 g Protein.",
"en": "Whisk 40 g whey protein + 1 egg + 40 g oats (finely ground) + 60 ml milk/water. ≈25 g protein."
}
}
},
{
"id": "obst",
"cls": "obst",
"ic": "🍎",
"title": {
"de": "Obst",
"en": "Fruit"
},
"thumb": {
"de": "Frisch oder tiefgekühlt ohne Zuckerzusatz, Saison bevorzugen.",
"en": "Fresh or frozen without added sugar, prefer whats in season."
},
"items": [
{
"id": "apple",
"name": {
"de": "Apfel",
"en": "Apple"
},
"v": {
"de": "Vit. C, Kalium, Kupfer",
"en": "Vit. C, potassium, copper"
}
},
{
"id": "banana",
"name": {
"de": "Banane",
"en": "Banana"
},
"v": {
"de": "Kalium, B6, C, Magnesium",
"en": "Potassium, B6, C, magnesium"
},
"f": "caution"
},
{
"id": "blueberry",
"name": {
"de": "Blaubeere",
"en": "Blueberry"
},
"v": {
"de": "Vit. K, C, Mangan",
"en": "Vit. K, C, manganese"
},
"f": "pick"
},
{
"id": "strawberry",
"name": {
"de": "Erdbeere",
"en": "Strawberry"
},
"v": {
"de": "Vit. C, Mangan, Folsäure, Kalium",
"en": "Vit. C, manganese, folate, potassium"
}
},
{
"id": "granadilla",
"name": {
"de": "Granadilla (≈ Passionsfrucht)",
"en": "Granadilla (≈ passion fruit)"
},
"v": {
"de": "Vit. C, A, Kalium, B2",
"en": "Vit. C, A, potassium, B2"
},
"approx": true
},
{
"id": "pomegranate",
"name": {
"de": "Granatapfel",
"en": "Pomegranate"
},
"v": {
"de": "Vit. C, K, Folsäure, Kalium, B6",
"en": "Vit. C, K, folate, potassium, B6"
},
"f": "pick"
},
{
"id": "mango",
"name": {
"de": "Mango",
"en": "Mango"
},
"v": {
"de": "Vit. C, A, B6, Folsäure, Kupfer",
"en": "Vit. C, A, B6, folate, copper"
},
"f": "pick"
},
{
"id": "nectarine",
"name": {
"de": "Nektarine",
"en": "Nectarine"
},
"v": {
"de": "Vit. C, A, Kalium, E, Niacin",
"en": "Vit. C, A, potassium, E, niacin"
}
},
{
"id": "orange",
"name": {
"de": "Orange",
"en": "Orange"
},
"v": {
"de": "Vit. C, Folsäure, Kalium, Calcium",
"en": "Vit. C, folate, potassium, calcium"
}
},
{
"id": "passion_fruit",
"name": {
"de": "Passionsfrucht",
"en": "Passion fruit"
},
"v": {
"de": "Vit. C, A, Kalium, Eisen",
"en": "Vit. C, A, potassium, iron"
},
"approx": true
},
{
"id": "plum",
"name": {
"de": "Pflaume",
"en": "Plum"
},
"v": {
"de": "Vit. C, K, Kalium, A, Kupfer",
"en": "Vit. C, K, potassium, A, copper"
}
},
{
"id": "grapes",
"name": {
"de": "Trauben",
"en": "Grapes"
},
"v": {
"de": "Vit. K, C, Kalium, Kupfer, Mangan",
"en": "Vit. K, C, potassium, copper, manganese"
},
"f": "caution"
}
]
},
{
"id": "gemuese",
"cls": "gemuese",
"ic": "🥦",
"title": {
"de": "Gemüse",
"en": "Vegetables"
},
"thumbLabel": {
"de": "Tipp",
"en": "Tip"
},
"thumb": {
"de": "TK-Gemüse ist völlig okay, wenns schnell gehen muss — genauso nährstoffreich wie frisch, einfach ohne Salz-/Fettzusatz wählen.",
"en": "Frozen veg is perfectly fine when time is short — just as nutrient-rich as fresh, just pick it without added salt/fat."
},
"items": [
{
"id": "broccoli",
"name": {
"de": "Brokkoli",
"en": "Broccoli"
},
"v": {
"de": "Vit. C, K, Folsäure, Eisen",
"en": "Vit. C, K, folate, iron"
}
},
{
"id": "mushrooms",
"name": {
"de": "Champignons",
"en": "Mushrooms"
},
"v": {
"de": "Kalium, Selen, Kupfer, B2",
"en": "Potassium, selenium, copper, B2"
}
},
{
"id": "iceberg_lettuce",
"name": {
"de": "Eisbergsalat",
"en": "Iceberg lettuce"
},
"v": {
"de": "Vit. K, A, Folsäure, Kalium — nährstoffarm",
"en": "Vit. K, A, folate, potassium — low nutrient density"
},
"f": "caution"
},
{
"id": "lambs_lettuce",
"name": {
"de": "Feldsalat",
"en": "Lambs lettuce"
},
"v": {
"de": "Vit. A, C, Folsäure, Eisen",
"en": "Vit. A, C, folate, iron"
},
"f": "pick"
},
{
"id": "cucumber",
"name": {
"de": "Gurke",
"en": "Cucumber"
},
"v": {
"de": "Vit. K, Kalium, Mangan",
"en": "Vit. K, potassium, manganese"
}
},
{
"id": "carrot",
"name": {
"de": "Karotte",
"en": "Carrot"
},
"v": {
"de": "Vit. A, K, Kalium, B6",
"en": "Vit. A, K, potassium, B6"
},
"f": "pick"
},
{
"id": "bell_pepper",
"name": {
"de": "Paprika",
"en": "Bell pepper"
},
"v": {
"de": "Vit. C, A, B6, E",
"en": "Vit. C, A, B6, E"
}
},
{
"id": "spinach",
"name": {
"de": "Spinat",
"en": "Spinach"
},
"v": {
"de": "Vit. K, A, Folsäure, Eisen",
"en": "Vit. K, A, folate, iron"
},
"f": "pick"
},
{
"id": "tomato",
"name": {
"de": "Tomate",
"en": "Tomato"
},
"v": {
"de": "Vit. C, K, Kalium, Folsäure",
"en": "Vit. C, K, potassium, folate"
}
},
{
"id": "zucchini",
"name": {
"de": "Zucchini",
"en": "Zucchini"
},
"v": {
"de": "Vit. C, Kalium, B6, Mangan",
"en": "Vit. C, potassium, B6, manganese"
}
}
],
"note": {
"de": "Hinweis: Süßkartoffel, Kochbanane und Erbsen zählen hier als Kohlenhydratquelle, Avocado als Fettquelle — siehe dort.",
"en": "Note: sweet potato, plantain and peas count here as a carb source, avocado as a fat source — see those cards."
}
},
{
"id": "fett",
"cls": "fett",
"ic": "🥑",
"title": {
"de": "Fett",
"en": "Fat"
},
"thumb": {
"de": "Native/kaltgepresste Öle, ungesalzene & ungeröstete Nüsse/Kerne, Zutatenliste = nur die Zutat selbst.",
"en": "Native/cold-pressed oils, unsalted & unroasted nuts/seeds, ingredient list = just the ingredient itself."
},
"items": [
{
"id": "avocado",
"name": {
"de": "Avocado",
"en": "Avocado"
},
"v": {
"de": "217 kcal · 12,5 g",
"en": "217 kcal · 12.5 g"
}
},
{
"id": "chia_seeds",
"name": {
"de": "Chiasamen",
"en": "Chia seeds"
},
"v": {
"de": "486 kcal · 31 g",
"en": "486 kcal · 31 g"
},
"f": "pick"
},
{
"id": "pumpkin_seeds",
"name": {
"de": "Kürbiskerne",
"en": "Pumpkin seeds"
},
"v": {
"de": "570 kcal · 44 g",
"en": "570 kcal · 44 g"
}
},
{
"id": "linseed_oil",
"name": {
"de": "Leinöl",
"en": "Linseed oil"
},
"v": {
"de": "837 kcal · 93 g",
"en": "837 kcal · 93 g"
},
"f": "pick"
},
{
"id": "almonds",
"name": {
"de": "Mandeln",
"en": "Almonds"
},
"v": {
"de": "612 kcal · 55 g",
"en": "612 kcal · 55 g"
}
},
{
"id": "olive_oil",
"name": {
"de": "Olivenöl",
"en": "Olive oil"
},
"v": {
"de": "857 kcal · 91,5 g",
"en": "857 kcal · 91.5 g"
}
},
{
"id": "walnuts",
"name": {
"de": "Walnüsse",
"en": "Walnuts"
},
"v": {
"de": "674 kcal · 62,5 g",
"en": "674 kcal · 62.5 g"
},
"f": "pick"
}
]
},
{
"id": "kh",
"cls": "kh",
"ic": "🌾",
"title": {
"de": "Kohlenhydrate",
"en": "Carbohydrates"
},
"thumb": {
"de": "Vollkorn als erste Zutat, wenig zugesetzter Zucker. Faustregel 10:1 — pro 10 g Kohlenhydrate mind. 1 g Ballaststoffe (z. B. 30 g KH → mind. 3 g Ballaststoffe).",
"en": "Whole grain as the first ingredient, low added sugar. 10:1 rule of thumb — at least 1 g fibre per 10 g carbs (e.g. 30 g carbs → at least 3 g fibre)."
},
"items": [
{
"id": "brown_lentils",
"name": {
"de": "Berglinsen",
"en": "Brown lentils"
},
"v": {
"de": "319 kcal · ≈46 g",
"en": "319 kcal · ≈46 g"
},
"approx": true,
"f": "pick"
},
{
"id": "buckwheat",
"name": {
"de": "Buchweizen",
"en": "Buckwheat"
},
"v": {
"de": "343 kcal · 71,5 g",
"en": "343 kcal · 71.5 g"
}
},
{
"id": "puffed_spelt",
"name": {
"de": "Dinkel gepufft",
"en": "Puffed spelt"
},
"v": {
"de": "≈338 kcal · 70 g",
"en": "≈338 kcal · 70 g"
},
"approx": true
},
{
"id": "spelt_pasta",
"name": {
"de": "Dinkel-Vollkorn-Nudeln",
"en": "Whole-grain spelt pasta"
},
"v": {
"de": "≈338 kcal · 70 g",
"en": "≈338 kcal · 70 g"
},
"approx": true
},
{
"id": "peas",
"name": {
"de": "Erbsen",
"en": "Peas"
},
"v": {
"de": "93 kcal · 10,4 g",
"en": "93 kcal · 10.4 g"
}
},
{
"id": "oats",
"name": {
"de": "Haferflocken",
"en": "Oats"
},
"v": {
"de": "372 kcal · 66,3 g",
"en": "372 kcal · 66.3 g"
}
},
{
"id": "millet",
"name": {
"de": "Hirse",
"en": "Millet"
},
"v": {
"de": "363 kcal · 69 g",
"en": "363 kcal · 69 g"
},
"f": "caution"
},
{
"id": "chickpeas",
"name": {
"de": "Kichererbsen",
"en": "Chickpeas"
},
"v": {
"de": "108 kcal · 14,2 g",
"en": "108 kcal · 14.2 g"
}
},
{
"id": "chickpea_pasta",
"name": {
"de": "Kichererbsennudeln / -couscous",
"en": "Chickpea pasta / couscous"
},
"v": {
"de": "≈335 kcal · 50 g",
"en": "≈335 kcal · 50 g"
},
"approx": true
},
{
"id": "plantain",
"name": {
"de": "Kochbanane",
"en": "Plantain"
},
"v": {
"de": "≈122 kcal · 32 g",
"en": "≈122 kcal · 32 g"
},
"approx": true,
"f": "caution"
},
{
"id": "lentil_pasta",
"name": {
"de": "Linsennudeln",
"en": "Lentil pasta"
},
"v": {
"de": "≈335 kcal · 52 g",
"en": "≈335 kcal · 52 g"
},
"approx": true
},
{
"id": "quinoa",
"name": {
"de": "Quinoa",
"en": "Quinoa"
},
"v": {
"de": "368 kcal · 64,2 g",
"en": "368 kcal · 64.2 g"
}
},
{
"id": "red_lentils",
"name": {
"de": "Rote Linsen",
"en": "Red lentils"
},
"v": {
"de": "326 kcal · ≈52 g",
"en": "326 kcal · ≈52 g"
},
"approx": true,
"f": "pick"
},
{
"id": "sweet_potato",
"name": {
"de": "Süßkartoffel",
"en": "Sweet potato"
},
"v": {
"de": "86 kcal · 20,1 g",
"en": "86 kcal · 20.1 g"
},
"f": "caution"
},
{
"id": "french_lentils",
"name": {
"de": "Tellerlinsen",
"en": "French lentils"
},
"v": {
"de": "234 kcal · ≈34 g",
"en": "234 kcal · ≈34 g"
},
"approx": true,
"f": "pick"
}
]
}
]

View File

@@ -0,0 +1,160 @@
[
{
"id": "bf_skyr_oats_blueberry",
"type": "breakfast",
"name": {
"de": "Skyr + Haferflocken + Blaubeeren",
"en": "Skyr + oats + blueberries"
},
"uses": ["skyr", "oats", "blueberry"]
},
{
"id": "bf_eggs_tomato",
"type": "breakfast",
"name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" },
"uses": ["egg", "tomato"]
},
{
"id": "bf_quark_pomegranate",
"type": "breakfast",
"name": {
"de": "Magerquark + Granatapfel",
"en": "Low-fat quark + pomegranate"
},
"uses": ["low_fat_quark", "pomegranate"]
},
{
"id": "sn_skyr_blueberry",
"type": "snack",
"name": { "de": "Skyr + Blaubeeren", "en": "Skyr + blueberries" },
"uses": ["skyr", "blueberry"]
},
{
"id": "sn_cottage_cucumber",
"type": "snack",
"name": {
"de": "Hüttenkäse + Gurke",
"en": "Cottage cheese + cucumber"
},
"uses": ["cottage_cheese", "cucumber"]
},
{
"id": "sn_quark_pomegranate",
"type": "snack",
"name": {
"de": "Magerquark + Granatapfel",
"en": "Low-fat quark + pomegranate"
},
"uses": ["low_fat_quark", "pomegranate"]
},
{
"id": "sn_protein_pudding",
"type": "snack",
"name": { "de": "Proteinpudding", "en": "Protein pudding" },
"uses": ["protein_pudding"]
},
{
"id": "full_chicken_cooked",
"type": "full",
"cooked": true,
"name": {
"de": "Hühnerbrust warm + Gemüse + Leinöl + Rote Linsen",
"en": "Warm chicken breast + veg + linseed oil + red lentils"
},
"uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"]
},
{
"id": "full_cold_platter",
"type": "full",
"cooked": false,
"name": {
"de": "Kalte Brotzeit: Feta + Feldsalat + Walnüsse + Nudelsalat",
"en": "Cold platter: feta + lamb's lettuce + walnuts + pasta salad"
},
"uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"]
},
{
"id": "full_bowl_nocook",
"type": "full",
"cooked": false,
"name": {
"de": "Bowl: Räuchertofu + Spinat + Avocado + Tellerlinsen (Dose)",
"en": "Bowl: smoked tofu + spinach + avocado + tinned French lentils"
},
"uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"]
},
{
"id": "full_leftovers_cold",
"type": "full",
"cooked": false,
"name": {
"de": "Resteteller: Hühnerbrust + Karotte + Walnüsse + Rote Linsen (kalt)",
"en": "Leftovers plate: chicken + carrot + walnuts + red lentils (cold)"
},
"uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"]
},
{
"id": "rec_protein_shake",
"type": "recipe",
"name": { "de": "Proteinshake — schnell", "en": "Protein shake — quick" },
"uses": ["protein_shake"],
"text": {
"de": "40 g Proteinpulver + 250300 ml Wasser oder Milch shaken. ≈30 g Protein.",
"en": "Shake 40 g protein powder with 250300 ml water or milk. ≈30 g protein."
}
},
{
"id": "rec_tempeh_pan",
"type": "recipe",
"name": { "de": "Tempeh-Pfanne", "en": "Pan-fried tempeh" },
"uses": ["tempeh", "soy_sauce", "lime", "garlic"],
"text": {
"de": "Tempeh würfeln, 10 Min. in Sojasauce + Limette + Knoblauch marinieren, knusprig braten (57 Min.).",
"en": "Cube tempeh, marinate 10 min in soy sauce + lime + garlic, pan-fry crisp (57 min)."
}
},
{
"id": "rec_silken_tofu_pudding",
"type": "recipe",
"name": {
"de": "Schoko-Seidentofu-Pudding",
"en": "Chocolate silken-tofu pudding"
},
"uses": ["silken_tofu", "cocoa_powder", "honey"],
"text": {
"de": "150 g Seidentofu + 1 EL Kakaopulver + 1 TL Honig pürieren, 30 Min. kalt stellen.",
"en": "Blend 150 g silken tofu + 1 tbsp cocoa powder + 1 tsp honey, chill 30 min."
}
},
{
"id": "rec_veggie_stirfry_base",
"type": "recipe",
"name": {
"de": "Grundrezept: Gemüsepfanne",
"en": "Base recipe: vegetable stir-fry"
},
"uses": ["carrot", "bell_pepper", "zucchini", "olive_oil"],
"text": {
"de": "Gemüse in mundgerechte Stücke schneiden. In heißem Öl 58 Min. anbraten. Protein dazugeben, kurz mitbraten, würzen, fertig.",
"en": "Cut vegetables into bite-size pieces. Pan-fry in hot oil 58 min. Add protein, fry briefly, season, done."
},
"variations": [
{
"de": "Asiatisch: Ingwer + Sojasauce + Sesamöl",
"en": "Asian: ginger + soy sauce + sesame oil"
},
{
"de": "Mediterran: Olivenöl + Zitrone + Oregano",
"en": "Mediterranean: olive oil + lemon + oregano"
},
{
"de": "Mexikanisch: Kreuzkümmel + Paprikapulver + Limette",
"en": "Mexican: cumin + paprika powder + lime"
},
{
"de": "Protein nach Wahl: Hühnerbrust, Tempeh, Räuchertofu oder Kichererbsen",
"en": "Protein of choice: chicken breast, tempeh, smoked tofu, or chickpeas"
}
]
}
]

View File

@@ -0,0 +1,66 @@
[
{
"n": 1,
"cls": "g",
"title": {
"de": "Protein",
"en": "Protein"
},
"qty": {
"de": "30 g pro Mahlzeit",
"en": "30 g per meal"
},
"text": {
"de": "Bspw. 125 g Rinderfilet, 300 g Skyr, 40 g Proteinpulver, 50 g Erbsenproteingeschnetzeltes oder 5 Eier. 150 g am Tag insgesamt.",
"en": "E.g. 125 g beef fillet, 300 g Skyr, 40 g protein powder, 50 g pea-protein strips, or 5 eggs. 150 g total per day."
}
},
{
"n": 2,
"cls": "t",
"title": {
"de": "Obst ODER Gemüse",
"en": "Fruit OR Vegetables"
},
"qty": {
"de": "eine Portion",
"en": "one portion"
},
"text": {
"de": "Obst = 100 g oder eine geballte Faust. Gemüse = 200 g oder 2 Handvoll.",
"en": "Fruit = 100 g or one clenched fist. Vegetables = 200 g or 2 handfuls."
}
},
{
"n": 3,
"cls": "o",
"title": {
"de": "Fett",
"en": "Fat"
},
"qty": {
"de": "",
"en": ""
},
"text": {
"de": "15 g Samen/Kerne/Nüsse (≈20 Stück, 1 EL) · ¼ Avocado · 1 EL Öl (15 g) — Nussöl außer Erdnuss, Olivenöl, Leinsamenöl.",
"en": "15 g seeds/nuts (≈20 pieces, 1 tbsp) · ¼ avocado · 1 tbsp oil (15 g) — nut oil except peanut, olive oil, linseed oil."
}
},
{
"n": 4,
"cls": "s",
"title": {
"de": "Kohlenhydrate",
"en": "Carbohydrates"
},
"qty": {
"de": "",
"en": ""
},
"text": {
"de": "75 g Reis/Nudeln/Brot (trocken) · ca. 250 g rohe Kartoffeln (≈3 mittelgroße) · 50 g Haferflocken (≈5 gehäufte EL).",
"en": "75 g rice/pasta/bread (dry) · approx. 250 g raw potatoes (≈3 medium) · 50 g oats (≈5 heaped tbsp)."
}
}
]

148
client/src/i18n/ui.ts Normal file
View File

@@ -0,0 +1,148 @@
import type { Language, LocalizedString } from "@/types/domain"
/**
* Resolve a UI chrome key object (or any LocalizedString) for the active language.
* Mirrors the prototype `t(key)` helper when called as `t(ui.nav.home, lang)`.
*/
export function t(value: LocalizedString, lang: Language): string {
return value[lang]
}
/**
* Resolve a data LocalizedString (`name`, `text`, …) — prototype `tr(obj)`.
*/
export function tr(value: LocalizedString, lang: Language): string {
return value[lang]
}
export const ui = {
appName: { de: "Pocket Pascal", en: "Pocket Pascal" },
nav: {
home: { de: "Start", en: "Home" },
pantry: { de: "Vorrat", en: "Pantry" },
knowledge: { de: "Teller", en: "Plate" },
settings: { de: "Einstellungen", en: "Settings" },
},
home: {
title: { de: "Was esse ich jetzt?", en: "What do I eat now?" },
editLayout: { de: "Layout", en: "Layout" },
doneEdit: { de: "Fertig", en: "Done" },
moveUp: { de: "Nach oben", en: "Move up" },
moveDown: { de: "Nach unten", en: "Move down" },
},
suggestion: {
morning: { de: "Morgen · Protein + Obst", en: "Morning · Protein + fruit" },
midday: { de: "Mittag · Ganzer Teller", en: "Midday · Full plate" },
afternoon: { de: "Nachmittag · Snack-Zeit", en: "Afternoon · Snack time" },
evening: { de: "Abend · Ganzer Teller", en: "Evening · Full plate" },
timeSuffix: { de: "Uhr", en: "" },
pinned: { de: "Fixiert", en: "Pinned" },
pin: { de: "Fixieren", en: "Pin" },
unpin: { de: "Lösen", en: "Unpin" },
reroll: { de: "Was anderes", en: "Something else" },
toBuilder: { de: "In den Builder übernehmen", en: "Send to builder" },
fallbackName: { de: "Dein Teller jetzt", en: "Your plate now" },
},
knowledgeHub: {
plateTitle: { de: "Mein Teller", en: "My Plate" },
plateBody: {
de: "⅓ Protein · ⅓ Gemüse/Obst · ⅓ Fett + Kohlenhydrate",
en: "⅓ protein · ⅓ veg/fruit · ⅓ fat + carbs",
},
pantryTitle: { de: "Vorratskammer", en: "Pantry" },
pantryBody: {
de: "Kategorien, Suche, Pick- & Caution-Markierungen",
en: "Categories, search, pick & caution markers",
},
weekplanTitle: { de: "Wochenplan", en: "Weekly plan" },
weekplanBody: {
de: "Frühstück, Snacks, volle Teller & Rezepte",
en: "Breakfast, snacks, full plates & recipes",
},
},
weekplan: {
title: { de: "Wochenplan", en: "Weekly plan" },
intro: {
de: "Kuratierte Kombinationen aus dem Baukasten — gruppiert nach Mahlzeitentyp.",
en: "Curated building-block combos — grouped by meal type.",
},
breakfast: { de: "Frühstück", en: "Breakfast" },
snack: { de: "Snack", en: "Snack" },
full: { de: "Ganzer Teller", en: "Full plate" },
recipe: { de: "Rezepte", en: "Recipes" },
cooked: { de: "Warm / gekocht", en: "Warm / cooked" },
cold: { de: "Kalt / ohne Kochen", en: "Cold / no cook" },
uses: { de: "Zutaten", en: "Ingredients" },
variations: { de: "Variationen", en: "Variations" },
},
pantry: {
title: { de: "Vorratskammer", en: "Pantry" },
search: { de: "Lebensmittel suchen…", en: "Search foods…" },
pick: { de: "empfohlen", en: "recommended" },
caution: { de: "bewusst genießen", en: "enjoy mindfully" },
noResults: { de: "Keine Treffer.", en: "No results." },
tip: { de: "Tipp", en: "Tip" },
},
plate: {
title: { de: "Mein Teller", en: "My Plate" },
subtitle: {
de: "Baukasten-Reihenfolge: zuerst Protein, dann Obst oder Gemüse, danach Fett und Kohlenhydrate.",
en: "Building-block order: protein first, then fruit or vegetables, then fat and carbohydrates.",
},
mealToggle: { de: "Mahlzeitentyp", en: "Meal type" },
segFull: { de: "Ganze Mahlzeit", en: "Full meal" },
segHalf: { de: "Halbe Mahlzeit", en: "Half meal" },
plateAria: {
de: "Tellerverteilung nach Baukasten",
en: "Plate breakdown by building blocks",
},
rulesTitle: { de: "Grundregeln", en: "Ground rules" },
rule1: {
de: "Jede Mahlzeit beruht auf Protein — egal ob süß, herzhaft, Snack oder Hauptmahlzeit.",
en: "Every meal is built on protein — sweet, savoury, snack, or main.",
},
rule2: {
de: "Jede Mahlzeit enthält eine Portion Gemüse oder Obst.",
en: "Every meal includes a portion of vegetables or fruit.",
},
rule3: {
de: "4 Mahlzeiten am Tag, ca. alle 34 Std.",
en: "4 meals a day, roughly every 34 hours.",
},
rule4: {
de: "2 L Wasser täglich.",
en: "2 L of water daily.",
},
},
builderSlot: {
title: { de: "Teller-Builder", en: "Plate builder" },
body: {
de: "Chips antippen, Kombination sofort gegen die Baukasten-Regeln prüfen.",
en: "Tap chips and check the combo against the building-block rules instantly.",
},
},
stubs: {
pantryTitle: { de: "Vorratskammer", en: "Pantry" },
pantryBody: {
de: "Phase-1-Platzhalter — Suche & Kategorien folgen.",
en: "Phase 1 stub — search & categories coming next.",
},
knowledgeTitle: { de: "Knowledge Base", en: "Knowledge Base" },
knowledgeBody: {
de: "Phase-1-Platzhalter — Teller-Regel & Daumenregeln folgen.",
en: "Phase 1 stub — plate rule & thumb rules coming next.",
},
builderTitle: { de: "Teller-Builder", en: "Plate builder" },
builderBody: {
de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen.",
en: "Phase 1 stub — chip selection & scoring coming next.",
},
builderHandoff: {
de: "Übernommen aus dem Vorschlag:",
en: "Handed off from suggestion:",
},
settingsTitle: { de: "Einstellungen", en: "Settings" },
language: { de: "Sprache", en: "Language" },
comingSoon: { de: "Demnächst", en: "Coming soon" },
},
} as const

View File

@@ -1,13 +1,14 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "./styles/theme.css";
@import "./styles/glass.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--font-heading: "Unbounded", ui-sans-serif, sans-serif;
--font-sans: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
@@ -121,10 +122,15 @@
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
body {
margin: 0;
min-height: 100svh;
color: var(--ink);
background: var(--page-gradient);
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
-webkit-tap-highlight-color: transparent;
}
}

139
client/src/lib/catalog.ts Normal file
View File

@@ -0,0 +1,139 @@
import type {
CategoryId,
CatalogItem,
ExtraItem,
FoodCategory,
FoodItem,
LocalizedString,
PlanRecipeType,
Recipe,
TagType,
TimeSlot,
} from "@/types/domain"
import extrasRaw from "@/data/extras.json"
import foodsRaw from "@/data/foods.json"
import recipesRaw from "@/data/recipes.json"
import stepsRaw from "@/data/steps.json"
interface RawFoodItem {
id: string
name: LocalizedString
v: LocalizedString
f?: TagType
approx?: boolean
}
interface RawFoodCategory {
id: CategoryId
cls: string
title: LocalizedString
thumb: LocalizedString
items: RawFoodItem[]
}
export interface PlateStep {
n: number
cls: string
title: LocalizedString
qty: LocalizedString
text: LocalizedString
}
function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem {
return {
id: item.id,
cat,
name: item.name,
valueInfo: item.v,
tag: item.f,
isApprox: item.approx,
}
}
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
(category) => ({
id: category.id,
cls: category.cls,
title: category.title,
thumb: category.thumb,
items: category.items.map((item) => mapItem(category.id, item)),
}),
)
export const foods: FoodItem[] = foodCategories.flatMap((category) => category.items)
export const foodById: Record<string, FoodItem> = Object.fromEntries(
foods.map((item) => [item.id, item]),
)
export const extras: ExtraItem[] = extrasRaw as ExtraItem[]
export const extraById: Record<string, ExtraItem> = Object.fromEntries(
extras.map((item) => [item.id, item]),
)
export const recipes: Recipe[] = recipesRaw as Recipe[]
export const plateSteps: PlateStep[] = stepsRaw as PlateStep[]
export const PLAN_TYPE_ORDER: PlanRecipeType[] = [
"breakfast",
"snack",
"full",
"recipe",
]
export function foodsInCategory(cat: CategoryId): FoodItem[] {
return foodCategories.find((category) => category.id === cat)?.items ?? []
}
export function resolveCatalogId(id: string): CatalogItem | null {
const food = foodById[id]
if (food) {
return {
id: food.id,
name: food.name,
source: "cat",
cat: food.cat,
valueInfo: food.valueInfo,
tag: food.tag,
}
}
const extra = extraById[id]
if (extra) {
return {
id: extra.id,
name: extra.name,
source: "extra",
}
}
return null
}
export function resolveRecipeUses(recipe: Recipe): CatalogItem[] {
return recipe.uses
.map((id) => resolveCatalogId(id))
.filter((item): item is CatalogItem => Boolean(item))
}
export function recipesByType(type: PlanRecipeType): Recipe[] {
return recipes.filter((recipe) => recipe.type === type)
}
/** Map plan recipe types onto suggestion time slots. */
export function timeSlotsForRecipe(recipe: Recipe): TimeSlot[] {
switch (recipe.type) {
case "breakfast":
return ["morning"]
case "snack":
return ["afternoon"]
case "full":
return ["midday", "evening"]
case "recipe":
return ["morning", "afternoon", "midday", "evening"]
}
}
export function mealTypeForRecipe(recipe: Recipe): "full" | "half" {
return recipe.type === "full" ? "full" : "half"
}

4
client/src/lib/cx.ts Normal file
View File

@@ -0,0 +1,4 @@
/** Tiny className joiner — avoids pulling in clsx/tailwind-merge as a dependency. */
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}

View File

@@ -0,0 +1,65 @@
import {
createContext,
createElement,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react"
import type { Language } from "@/types/domain"
const STORAGE_KEY = "pp.lang"
interface LanguageContextValue {
lang: Language
setLang: (lang: Language) => void
toggleLang: () => void
}
const LanguageContext = createContext<LanguageContextValue | null>(null)
function readStoredLanguage(): Language {
try {
const value = localStorage.getItem(STORAGE_KEY)
if (value === "de" || value === "en") return value
} catch {
// ignore
}
return "de"
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Language>(() =>
typeof window === "undefined" ? "de" : readStoredLanguage(),
)
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, lang)
} catch {
// ignore
}
document.documentElement.lang = lang
}, [lang])
const value = useMemo<LanguageContextValue>(
() => ({
lang,
setLang: setLangState,
toggleLang: () => setLangState((current) => (current === "de" ? "en" : "de")),
}),
[lang],
)
return createElement(LanguageContext.Provider, { value }, children)
}
export function useLanguage(): LanguageContextValue {
const ctx = useContext(LanguageContext)
if (!ctx) {
throw new Error("useLanguage must be used within LanguageProvider")
}
return ctx
}

View File

@@ -0,0 +1,103 @@
import { foodById, foods, recipes, timeSlotsForRecipe } from "@/lib/catalog"
import type { CategoryId, FoodItem, Recipe, TimeSlot } from "@/types/domain"
const SLOT_CATEGORIES: Record<TimeSlot, CategoryId[]> = {
morning: ["protein", "obst", "fett"],
midday: ["protein", "gemuese", "fett", "kh"],
afternoon: ["protein", "gemuese"],
evening: ["protein", "gemuese", "fett", "kh"],
}
function pickRandom<T>(items: T[], excludeIds: Set<string>, getId: (item: T) => string): T | null {
const pool = items.filter((item) => !excludeIds.has(getId(item)))
if (pool.length === 0) return null
return pool[Math.floor(Math.random() * pool.length)] ?? null
}
export function recipesForSlot(slot: TimeSlot): Recipe[] {
return recipes.filter(
(recipe) =>
recipe.type !== "recipe" && timeSlotsForRecipe(recipe).includes(slot),
)
}
/** Plate foods only (skip EXTRAS) for suggestion chips / builder handoff. */
export function resolveRecipeFoods(recipe: Recipe): FoodItem[] {
return recipe.uses
.map((id) => foodById[id])
.filter((item): item is FoodItem => Boolean(item))
}
/** Prefer curated recipes; fall back to one random food per expected category. */
export function buildSuggestion(slot: TimeSlot, avoidRecipeId?: string): {
recipe: Recipe | null
foods: FoodItem[]
} {
const candidates = recipesForSlot(slot).filter((recipe) => recipe.id !== avoidRecipeId)
const recipe =
candidates.length > 0
? (pickRandom(candidates, new Set(), (item) => item.id) as Recipe)
: null
if (recipe) {
return { recipe, foods: resolveRecipeFoods(recipe) }
}
const generated = SLOT_CATEGORIES[slot]
.map((cat) =>
pickRandom(
foods.filter((item) => item.cat === cat),
new Set(),
(item) => item.id,
),
)
.filter((item): item is FoodItem => Boolean(item))
return { recipe: null, foods: generated }
}
/**
* Replace unpinned ingredients with alternatives from the same category.
* Pinned IDs stay in place; missing categories are filled when possible.
*/
export function reshuffleSuggestion(
current: FoodItem[],
pinnedIds: Set<string>,
slot: TimeSlot,
): FoodItem[] {
const pinned = current.filter((item) => pinnedIds.has(item.id))
const pinnedCats = new Set(pinned.map((item) => item.cat))
const exclude = new Set(current.map((item) => item.id))
const next: FoodItem[] = [...pinned]
for (const item of current) {
if (pinnedIds.has(item.id)) continue
const replacement = pickRandom(
foods.filter((candidate) => candidate.cat === item.cat),
exclude,
(candidate) => candidate.id,
)
if (replacement) {
exclude.add(replacement.id)
next.push(replacement)
} else {
next.push(item)
}
}
for (const cat of SLOT_CATEGORIES[slot]) {
if (pinnedCats.has(cat) || next.some((item) => item.cat === cat)) continue
const fill = pickRandom(
foods.filter((candidate) => candidate.cat === cat),
exclude,
(candidate) => candidate.id,
)
if (fill) {
exclude.add(fill.id)
next.push(fill)
}
}
return next
}

View File

@@ -0,0 +1,36 @@
import type { MealType, TimeSlot } from "@/types/domain"
export interface TimeContext {
slot: TimeSlot
mealType: MealType
hour: number
minute: number
}
/** Local-time meal context for the suggestion engine. */
export function getTimeContext(date = new Date()): TimeContext {
const hour = date.getHours()
const minute = date.getMinutes()
if (hour >= 6 && hour < 11) {
return { slot: "morning", mealType: "half", hour, minute }
}
if (hour >= 11 && hour < 15) {
return { slot: "midday", mealType: "full", hour, minute }
}
if (hour >= 15 && hour < 18) {
return { slot: "afternoon", mealType: "half", hour, minute }
}
if (hour >= 18 && hour < 22) {
return { slot: "evening", mealType: "full", hour, minute }
}
// Late night / early morning: lean snack context
return { slot: "afternoon", mealType: "half", hour, minute }
}
export function formatClock(hour: number, minute: number, lang: "de" | "en"): string {
const hh = String(hour).padStart(2, "0")
const mm = String(minute).padStart(2, "0")
return lang === "de" ? `${hh}:${mm}` : `${hh}:${mm}`
}

View File

@@ -0,0 +1,55 @@
import type { HomeWidgetId, WidgetOrder } from "@/types/domain"
const STORAGE_KEY = "pp.home.widgetOrder"
export const DEFAULT_WIDGET_ORDER: WidgetOrder = [
"suggestion",
"knowledge",
"builder",
]
function isWidgetId(value: unknown): value is HomeWidgetId {
return value === "suggestion" || value === "knowledge" || value === "builder"
}
export function readWidgetOrder(): WidgetOrder {
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return DEFAULT_WIDGET_ORDER
const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed) || parsed.length !== 3 || !parsed.every(isWidgetId)) {
return DEFAULT_WIDGET_ORDER
}
const unique = new Set(parsed)
if (unique.size !== 3) return DEFAULT_WIDGET_ORDER
return parsed
} catch {
return DEFAULT_WIDGET_ORDER
}
}
export function writeWidgetOrder(order: WidgetOrder): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(order))
} catch {
// ignore
}
}
export function moveWidget(
order: WidgetOrder,
id: HomeWidgetId,
direction: "up" | "down",
): WidgetOrder {
const index = order.indexOf(id)
if (index < 0) return order
const target = direction === "up" ? index - 1 : index + 1
if (target < 0 || target >= order.length) return order
const next = [...order]
const current = next[index]
const swap = next[target]
if (current === undefined || swap === undefined) return order
next[index] = swap
next[target] = current
return next
}

View File

@@ -0,0 +1,53 @@
import { useLocation } from "react-router-dom"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, tr, ui } from "@/i18n/ui"
import { foodById } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import type { BuilderHandoffState } from "@/types/domain"
export function BuilderScreen() {
const { lang } = useLanguage()
const location = useLocation()
const handoff = location.state as BuilderHandoffState | null
const ingredientIds = handoff?.ingredientIds ?? []
return (
<div className="flex flex-col gap-4">
<GlassCard className="p-4">
<p className="type-label text-[var(--ink-faint)]">
{t(ui.stubs.comingSoon, lang)}
</p>
<h1 className="type-display mt-2 text-[var(--ink)]">
{t(ui.stubs.builderTitle, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.stubs.builderBody, lang)}
</p>
</GlassCard>
{ingredientIds.length > 0 ? (
<GlassCard className="p-4">
<p className="type-body-emphasis text-[var(--ink)]">
{t(ui.stubs.builderHandoff, lang)}
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{ingredientIds.map((id) => {
const item = foodById[id]
if (!item) return null
return (
<li
key={id}
className="type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)] ring-1 ring-[var(--border)]"
style={{ background: "var(--glass-fill)" }}
>
{tr(item.name, lang)}
</li>
)
})}
</ul>
</GlassCard>
) : null}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { useMemo, useState } from "react"
import { Accordion } from "@/components/ui-pp/Accordion"
import { t, tr, ui } from "@/i18n/ui"
import { foodCategories } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import type { FoodItem } from "@/types/domain"
function matchesQuery(item: FoodItem, query: string, lang: "de" | "en"): boolean {
if (!query) return true
return tr(item.name, lang).toLowerCase().includes(query)
}
export function PantryScreen() {
const { lang } = useLanguage()
const [query, setQuery] = useState("")
const normalized = query.trim().toLowerCase()
const sections = useMemo(
() =>
foodCategories
.map((category) => {
const items = category.items.filter((item) => matchesQuery(item, normalized, lang))
return { category, items }
})
.filter((section) => section.items.length > 0),
[lang, normalized],
)
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.pantry.title, lang)}
</h1>
<p className="type-caption mt-2 text-[var(--ink-soft)]">
<span className="text-[var(--color-pick)]"></span> {t(ui.pantry.pick, lang)}
{" · "}
<span className="text-[var(--color-caution)]"></span> {t(ui.pantry.caution, lang)}
</p>
</header>
<label className="sr-only" htmlFor="pantry-search">
{t(ui.pantry.search, lang)}
</label>
<input
id="pantry-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t(ui.pantry.search, lang)}
className={cx(
"type-body min-h-[48px] w-full rounded-[var(--radius-control)] px-4 text-[var(--ink)]",
"ring-1 ring-[var(--border)] outline-none placeholder:text-[var(--ink-faint)]",
"focus-visible:ring-2 focus-visible:ring-[var(--color-kh)]/40",
)}
style={{ background: "var(--glass-fill)" }}
/>
{sections.length === 0 ? (
<p className="type-body text-[var(--ink-soft)]">{t(ui.pantry.noResults, lang)}</p>
) : (
<div className="flex flex-col gap-3">
{sections.map(({ category, items }) => (
<Accordion
key={category.id}
defaultOpen={Boolean(normalized)}
title={tr(category.title, lang)}
meta={items.length}
icon={
<span aria-hidden>
{category.id === "protein"
? "🥚"
: category.id === "snack"
? "🥤"
: category.id === "obst"
? "🍎"
: category.id === "gemuese"
? "🥦"
: category.id === "fett"
? "🥑"
: "🌾"}
</span>
}
>
<ul className="flex flex-col gap-2">
{items.map((item) => (
<li
key={item.id}
className="flex items-baseline justify-between gap-3 border-b border-[var(--border)] pb-2 last:border-0 last:pb-0"
>
<span className="type-body-emphasis text-[var(--ink)]">
{tr(item.name, lang)}
</span>
<span
className={cx(
"type-caption shrink-0 text-right text-[var(--ink-soft)]",
item.isApprox && "italic",
)}
>
{item.tag === "pick" ? (
<span className="mr-1 text-[var(--color-pick)]"></span>
) : null}
{item.tag === "caution" ? (
<span className="mr-1 text-[var(--color-caution)]"></span>
) : null}
{tr(item.valueInfo, lang)}
</span>
</li>
))}
</ul>
<p className="type-caption mt-3 text-[var(--ink-soft)]">
<b className="font-bold text-[var(--ink)]">{t(ui.pantry.tip, lang)}</b>
<br />
{tr(category.thumb, lang)}
</p>
</Accordion>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,283 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { ChevronRight } from "lucide-react"
import { DiningPlate } from "@/components/plate/DiningPlate"
import { SegControl } from "@/components/ui-pp/SegControl"
import { t, tr, ui } from "@/i18n/ui"
import { plateSteps, type PlateStep } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
type MealMode = "full" | "half"
const CLS_COLOR: Record<string, string> = {
g: "var(--green)",
t: "var(--terracotta)",
o: "var(--gold)",
s: "var(--slate)",
}
const FULL_WEIGHT: Record<string, number> = {
g: 2,
t: 2,
o: 1,
s: 1,
}
function stepColor(cls: string): string {
return CLS_COLOR[cls] ?? "var(--ink-faint)"
}
function StepDetailCard({
step,
open,
highlighted,
onToggle,
lang,
}: {
step: PlateStep
open: boolean
highlighted: boolean
onToggle: () => void
lang: "de" | "en"
}) {
const color = stepColor(step.cls)
const qty = tr(step.qty, lang)
const panelId = `plate-step-panel-${step.n}`
return (
<div
id={`plate-step-${step.n}`}
className={cx(
"overflow-hidden rounded-[var(--radius-card)] shadow-[var(--shadow-elevated)]",
"transition-[outline,outline-offset] duration-300",
)}
style={{
backgroundImage: "var(--surface-gradient)",
outline: highlighted ? `2px solid ${color}` : "2px solid transparent",
outlineOffset: 2,
}}
>
<div className="relative">
<div
className="absolute inset-0 rounded-[inherit]"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
<div className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]" />
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={onToggle}
className={cx(
"relative z-10 flex min-h-[56px] w-full items-center gap-3 px-4 py-3 text-left",
"no-select [-webkit-tap-highlight-color:transparent]",
"active:brightness-[0.98]",
)}
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full type-button text-white"
style={{
background: color,
color: step.cls === "o" ? "var(--ink)" : "#fff",
}}
>
{step.n}
</span>
<span className="type-title-sm min-w-0 flex-1 text-[var(--ink)]">
{tr(step.title, lang)}
</span>
{qty ? (
<span className="type-caption shrink-0 max-w-[40%] text-right font-bold text-[var(--ink-soft)]">
{qty}
</span>
) : null}
<ChevronRight
aria-hidden
className={cx(
"h-5 w-5 shrink-0 text-[var(--ink-faint)] transition-transform duration-150",
open && "rotate-90",
)}
/>
</button>
{open ? (
<div
id={panelId}
className="relative z-10 border-t border-[var(--border)] px-4 py-3"
>
<p className="type-body text-[var(--ink-soft)]">{tr(step.text, lang)}</p>
</div>
) : null}
</div>
</div>
)
}
export function PlateScreen() {
const { lang } = useLanguage()
const [mode, setMode] = useState<MealMode>("full")
const [openIds, setOpenIds] = useState<Set<number>>(() => new Set([1]))
const [highlightId, setHighlightId] = useState<number | null>(null)
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const visibleSteps = useMemo(
() =>
mode === "full"
? plateSteps
: plateSteps.filter((step) => step.cls === "g" || step.cls === "t"),
[mode],
)
const segments = useMemo(
() =>
visibleSteps.map((step) => ({
id: String(step.n),
weight: mode === "full" ? (FULL_WEIGHT[step.cls] ?? 1) : 1,
color: stepColor(step.cls),
label: tr(step.title, lang),
})),
[visibleSteps, mode, lang],
)
useEffect(() => {
return () => {
if (highlightTimer.current) clearTimeout(highlightTimer.current)
}
}, [])
useEffect(() => {
// Drop open state for cards that are hidden in half-meal mode
setOpenIds((current) => {
const next = new Set<number>()
for (const id of current) {
if (visibleSteps.some((step) => step.n === id)) next.add(id)
}
if (next.size === 0 && visibleSteps[0]) next.add(visibleSteps[0].n)
return next
})
}, [visibleSteps])
function focusStep(n: number) {
setOpenIds((current) => new Set(current).add(n))
setHighlightId(n)
if (highlightTimer.current) clearTimeout(highlightTimer.current)
highlightTimer.current = setTimeout(() => setHighlightId(null), 300)
requestAnimationFrame(() => {
document
.getElementById(`plate-step-${n}`)
?.scrollIntoView({ behavior: "smooth", block: "nearest" })
})
}
function toggleStep(n: number) {
setOpenIds((current) => {
const next = new Set(current)
if (next.has(n)) next.delete(n)
else next.add(n)
return next
})
}
const rules = [ui.plate.rule1, ui.plate.rule2, ui.plate.rule3, ui.plate.rule4]
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">{t(ui.plate.title, lang)}</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.plate.subtitle, lang)}
</p>
</header>
<SegControl
aria-label={t(ui.plate.mealToggle, lang)}
value={mode}
onChange={setMode}
options={[
{ value: "full", label: t(ui.plate.segFull, lang) },
{ value: "half", label: t(ui.plate.segHalf, lang) },
]}
/>
<div className="flex flex-col items-center gap-4 py-2">
<DiningPlate
key={mode}
size={280}
segments={segments}
aria-label={t(ui.plate.plateAria, lang)}
onSelect={(id) => focusStep(Number(id))}
/>
<ul className="flex w-full flex-col gap-2">
{visibleSteps.map((step) => (
<li key={step.n}>
<button
type="button"
onClick={() => focusStep(step.n)}
className={cx(
"type-caption flex min-h-[44px] w-full items-center justify-center gap-2 font-bold text-[var(--ink-soft)]",
"rounded-[var(--radius-control)] px-2 active:brightness-95",
)}
>
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ background: stepColor(step.cls) }}
aria-hidden
/>
<span>{tr(step.title, lang)}</span>
</button>
</li>
))}
</ul>
</div>
<div className="flex flex-col gap-3">
{visibleSteps.map((step) => (
<div
key={step.n}
className={cx(
"transition-[opacity,transform] duration-300 ease-out",
"motion-reduce:transition-none",
)}
>
<StepDetailCard
step={step}
open={openIds.has(step.n)}
highlighted={highlightId === step.n}
onToggle={() => toggleStep(step.n)}
lang={lang}
/>
</div>
))}
</div>
<footer className="mt-2 rounded-[var(--radius-card)] p-4 ring-1 ring-[var(--border)]"
style={{ background: "var(--glass-fill)" }}
>
<p className="type-label mb-3 text-[var(--ink-faint)]">
{t(ui.plate.rulesTitle, lang)}
</p>
<ul className="flex flex-col gap-2">
{rules.map((rule, index) => (
<li
key={index}
className="type-body flex gap-2 text-[var(--ink-soft)]"
>
<span className="text-[var(--ink-faint)]" aria-hidden>
</span>
<span>{t(rule, lang)}</span>
</li>
))}
</ul>
</footer>
</div>
)
}

View File

@@ -0,0 +1,58 @@
import { Button } from "@/components/ui-pp/Button"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import type { Language } from "@/types/domain"
const OPTIONS: { id: Language; label: string }[] = [
{ id: "de", label: "Deutsch" },
{ id: "en", label: "English" },
]
export function SettingsScreen() {
const { lang, setLang } = useLanguage()
return (
<GlassCard className="p-4">
<h1 className="type-display text-[var(--ink)]">
{t(ui.stubs.settingsTitle, lang)}
</h1>
<div className="mt-6">
<p className="type-label text-[var(--ink-faint)]">
{t(ui.stubs.language, lang)}
</p>
<div className="mt-3 grid grid-cols-2 gap-2">
{OPTIONS.map((option) => (
<button
key={option.id}
type="button"
onClick={() => setLang(option.id)}
className={cx(
"elevation-transition min-h-[48px] rounded-[var(--radius-control)]",
"active:translate-y-px active:brightness-95",
lang === option.id
? "type-button text-[var(--ink)] shadow-[var(--shadow-elevated)]"
: "type-button-secondary text-[var(--ink-soft)] ring-1 ring-[var(--border)]",
)}
style={
lang === option.id
? { backgroundImage: "var(--gradient-primary)" }
: { background: "var(--glass-fill)" }
}
>
{option.label}
</button>
))}
</div>
</div>
<div className="mt-6">
<Button variant="secondary" className="w-full" disabled>
{t(ui.stubs.comingSoon, lang)}
</Button>
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,136 @@
import { useMemo, useState } from "react"
import { Accordion, NestedDisclosure } from "@/components/ui-pp/Accordion"
import { t, tr, ui } from "@/i18n/ui"
import { PLAN_TYPE_ORDER, recipesByType, resolveRecipeUses } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import type { PlanRecipeType, Recipe } from "@/types/domain"
const TYPE_ICON: Record<PlanRecipeType, string> = {
breakfast: "🌅",
snack: "🥤",
full: "🍽️",
recipe: "📖",
}
function typeLabel(type: PlanRecipeType, lang: "de" | "en"): string {
return t(ui.weekplan[type], lang)
}
function RecipeBody({ recipe, lang }: { recipe: Recipe; lang: "de" | "en" }) {
const ingredients = resolveRecipeUses(recipe)
return (
<div className="flex flex-col gap-3">
{recipe.cooked !== undefined ? (
<p className="type-label text-[var(--ink-faint)]">
{recipe.cooked ? t(ui.weekplan.cooked, lang) : t(ui.weekplan.cold, lang)}
</p>
) : null}
{recipe.text ? (
<p className="type-body text-[var(--ink-soft)]">{tr(recipe.text, lang)}</p>
) : null}
{recipe.variations && recipe.variations.length > 0 ? (
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.weekplan.variations, lang)}
</p>
<ul className="flex flex-col gap-2">
{recipe.variations.map((variation, index) => (
<li
key={`${recipe.id}-var-${index}`}
className="type-body flex gap-2 text-[var(--ink-soft)]"
>
<span className="text-[var(--ink-faint)]" aria-hidden>
·
</span>
<span>{tr(variation, lang)}</span>
</li>
))}
</ul>
</div>
) : null}
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.weekplan.uses, lang)}
</p>
<ul className="flex flex-wrap gap-2">
{ingredients.map((item) => (
<li
key={item.id}
className={cx(
"type-caption rounded-[var(--radius-pill)] px-3 py-2 font-bold text-[var(--ink)]",
"ring-1 ring-[var(--border)]",
)}
style={{ background: "var(--glass-fill)" }}
>
{tr(item.name, lang)}
{item.source === "extra" ? (
<span className="ml-1 text-[var(--ink-faint)]">+</span>
) : null}
</li>
))}
</ul>
</div>
</div>
)
}
export function WeekPlanScreen() {
const { lang } = useLanguage()
const [openTypes, setOpenTypes] = useState<Record<PlanRecipeType, boolean>>({
breakfast: true,
snack: false,
full: false,
recipe: false,
})
const groups = useMemo(
() =>
PLAN_TYPE_ORDER.map((type) => ({
type,
items: recipesByType(type),
})),
[],
)
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.weekplan.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.weekplan.intro, lang)}
</p>
</header>
<div className="flex flex-col gap-3">
{groups.map(({ type, items }) => (
<Accordion
key={type}
open={openTypes[type]}
onOpenChange={(open) =>
setOpenTypes((current) => ({ ...current, [type]: open }))
}
title={typeLabel(type, lang)}
meta={items.length}
icon={<span aria-hidden>{TYPE_ICON[type]}</span>}
>
<div className="flex flex-col gap-2">
{items.map((recipe) => (
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}>
<RecipeBody recipe={recipe} lang={lang} />
</NestedDisclosure>
))}
</div>
</Accordion>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,29 @@
/**
* Modal-only glass utilities.
*
* Home/surfaces use the elevated system in theme.css.
* True low-opacity glass is reserved for overlays/modals so content
* behind them stays readable without fighting the page gradient.
*/
:root {
--modal-glass-fill: rgba(255, 255, 255, 0.12);
--modal-glass-blur: 16px;
--modal-glass-border: rgba(255, 255, 255, 0.35);
--modal-glass-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
--modal-scrim: rgba(25, 24, 0, 0.45);
}
.modal-glass {
background: var(--modal-glass-fill);
backdrop-filter: blur(var(--modal-glass-blur));
-webkit-backdrop-filter: blur(var(--modal-glass-blur));
border: 1px solid var(--modal-glass-border);
box-shadow: var(--modal-glass-shadow);
}
.modal-scrim {
background: var(--modal-scrim);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}

270
client/src/styles/theme.css Normal file
View File

@@ -0,0 +1,270 @@
/**
* Pocket Pascal — Elevated Surface Design Tokens
*
* Pivoted from heavy glassmorphism to a low-transparency, tactile system:
* cards read as solid raised surfaces (elevation shadow + faint top/bottom
* bevel), buttons visibly depress on press (inset shadow + 1px downward
* shift) instead of just scaling. `--glass-*` names kept for minimal diff
* across components — they now mean "surface fill / surface blur", not
* "see-through glass". Blur is low (8px) and opacity high (~88%): it softens
* whatever sits behind a card without reading as transparent.
*
* Toggle dark mode by adding/removing the `dark` class on <html>
* (matches Tailwind `darkMode: 'class'` — see README).
*
* Brand hues (protein/obstgem/fett/kh + ink) come from the WCAG-audited
* Pocket Pascal palette, not generic placeholders — see /docs/CONCEPT.html
* section 05/08 for the full rationale and contrast numbers.
*/
:root {
/* ---------- Elevated surface (formerly "glass") ---------- */
--glass-fill: rgba(255, 255, 255, 0.9);
--glass-blur: 8px;
/* top-light / bottom-shadow bevel instead of an even glass edge — reads as raised, not see-through */
--glass-border-from: rgba(255, 255, 255, 0.9);
--glass-border-to: rgba(0, 0, 0, 0.06);
/* ---------- Radius scale (nested: outer ≈ inner + padding) ---------- */
--radius-card: 24px;
--radius-nested: 12px;
--radius-control: 16px;
--radius-pill: 9999px;
/* ---------- Shadows (resting → elevated → float; warm ink-tinted ambient) ---------- */
--shadow-resting: 0 1px 2px rgba(25, 24, 0, 0.04), 0 1px 3px rgba(25, 24, 0, 0.06);
--shadow-elevated: 0 2px 4px rgba(25, 24, 0, 0.04), 0 8px 20px rgba(25, 24, 0, 0.08);
--shadow-float: 0 4px 8px rgba(25, 24, 0, 0.05), 0 16px 32px rgba(25, 24, 0, 0.1);
--shadow-pressed: inset 0 2px 5px rgba(25, 24, 0, 0.22), inset 0 1px 1px rgba(25, 24, 0, 0.12);
/* BottomNav / overlays sit one step above content cards */
--shadow-ambient: var(--shadow-float);
/* ---------- Brand ink / neutrals ---------- */
--ink: #191800;
--ink-soft: #5c5b48;
--ink-faint: #6b6a56;
--border: #e4e2d8;
/* ---------- Brand category colors (bright primary + dark accent pairs) ---------- */
--color-protein: #d9f227;
--color-protein-dk: #2e3200;
--color-obstgem: #ff6a3d;
--color-obstgem-dk: #3d1508;
--color-fett: #ffb300;
--color-fett-dk: #3d2900;
--color-kh: #1fd9c4;
--color-kh-dk: #04302b;
--color-pick: #0f7a37;
--color-caution: #c81e1e;
/* ---------- Plate / Baukasten category hues (from prototype) ---------- */
--green: #2f5233;
--green-soft: #e3ece4;
--terracotta: #b33d22;
--terracotta-soft: #f7e0d8;
--gold: #e8b93a;
--gold-soft: #fdf1d9;
--slate: #6b4c9a;
--slate-soft: #efe9f5;
/* ---------- Surface gradients (subtle, sit UNDER glass layers) ---------- */
--surface-gradient: linear-gradient(160deg, #fafaf7 0%, #f0f0ea 100%);
--page-gradient: radial-gradient(120% 100% at 0% 0%, #fff9e0 0%, #ffffff 45%),
radial-gradient(120% 100% at 100% 100%, #e2fbf6 0%, #ffffff 45%);
/* ---------- Action gradients (vibrant, multi-stop) ---------- */
/* NOTE: brand hues are light/high-luminance by design (see contrast audit),
so action gradients pair with --ink text, not white — this keeps every
button at AA contrast without text-shadow hacks. Do not swap to white
text on these without re-checking contrast. */
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #c81e1e 100%);
/* ---------- Dining Plate chart tokens ---------- */
--plate-rim-light: #ffffff;
--plate-rim-shadow: #d8d8d0;
--plate-well-center: #f7f7f2;
--plate-well-edge: #e6e6dd;
}
.dark {
--glass-fill: rgba(28, 28, 21, 0.88);
--glass-blur: 8px;
--glass-border-from: rgba(255, 255, 255, 0.1);
--glass-border-to: rgba(0, 0, 0, 0.35);
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.25);
--shadow-elevated: 0 2px 4px rgba(0, 0, 0, 0.35), 0 10px 24px rgba(0, 0, 0, 0.4);
--shadow-float: 0 4px 8px rgba(0, 0, 0, 0.4), 0 18px 36px rgba(0, 0, 0, 0.45);
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.55), inset 0 1px 1px rgba(0, 0, 0, 0.3);
--shadow-ambient: var(--shadow-float);
--ink: #f3f2e6;
--ink-soft: #c2c1ac;
--ink-faint: #8f8e7a;
--border: #33342a;
/* category primaries stay identical (they're the brand, not a theme) */
--color-protein: #d9f227;
--color-protein-dk: #2e3200;
--color-obstgem: #ff6a3d;
--color-obstgem-dk: #3d1508;
--color-fett: #ffb300;
--color-fett-dk: #3d2900;
--color-kh: #1fd9c4;
--color-kh-dk: #04302b;
--color-pick: #4ade80;
--color-caution: #ff6b60;
--surface-gradient: linear-gradient(160deg, #1c1c15 0%, #14140f 100%);
--page-gradient: radial-gradient(120% 100% at 0% 0%, #2a2a12 0%, #14140f 45%),
radial-gradient(120% 100% at 100% 100%, #0f2a26 0%, #14140f 45%);
--gradient-primary: linear-gradient(135deg, #d9f227 0%, #1fd9c4 100%);
--gradient-accent: linear-gradient(135deg, #ff6a3d 0%, #ffb300 100%);
--gradient-caution: linear-gradient(135deg, #ff6a3d 0%, #ff6b60 100%);
--plate-rim-light: #2a2a22;
--plate-rim-shadow: #0c0c09;
--plate-well-center: #1c1c15;
--plate-well-edge: #111109;
}
/* ---------- Fonts ---------- */
/* Download the two static woff2 files listed in README into /public/fonts,
then this block just works. Using local @font-face (not next/font) keeps
this file framework-agnostic for Cursor. */
@font-face {
font-family: "Unbounded";
font-weight: 700;
font-style: normal;
font-display: swap;
src: url("/fonts/unbounded-700.woff2") format("woff2");
}
@font-face {
font-family: "Unbounded";
font-weight: 900;
font-style: normal;
font-display: swap;
src: url("/fonts/unbounded-900.woff2") format("woff2");
}
@font-face {
font-family: "Albert Sans";
font-weight: 400;
font-style: normal;
font-display: swap;
src: url("/fonts/albert-sans-400.woff2") format("woff2");
}
@font-face {
font-family: "Albert Sans";
font-weight: 500;
font-style: normal;
font-display: swap;
src: url("/fonts/albert-sans-500.woff2") format("woff2");
}
@font-face {
font-family: "Albert Sans";
font-weight: 700;
font-style: normal;
font-display: swap;
src: url("/fonts/albert-sans-700.woff2") format("woff2");
}
.font-display {
font-family: "Unbounded", ui-sans-serif, sans-serif;
letter-spacing: -0.01em;
}
.font-body {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
}
/* ---------- Type ramp (single hierarchy) ---------- */
.type-display {
font-family: "Unbounded", ui-sans-serif, sans-serif;
font-weight: 900;
font-size: 1.5rem; /* 24px */
line-height: 1.1;
letter-spacing: -0.01em;
}
.type-title {
font-family: "Unbounded", ui-sans-serif, sans-serif;
font-weight: 700;
font-size: 1.25rem; /* 20px */
line-height: 1.2;
letter-spacing: -0.01em;
}
.type-title-sm {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 700;
font-size: 1rem; /* 16px */
line-height: 1.3;
}
.type-body {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 400;
font-size: 0.875rem; /* 14px */
line-height: 1.5;
}
.type-body-emphasis {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 700;
font-size: 0.875rem;
line-height: 1.35;
}
.type-label {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 700;
font-size: 0.6875rem; /* 11px */
line-height: 1.3;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.type-caption {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 400;
font-size: 0.75rem; /* 12px */
line-height: 1.45;
}
.type-button {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 700;
font-size: 0.875rem;
line-height: 1.2;
}
.type-button-secondary {
font-family: "Albert Sans", ui-sans-serif, "Segoe UI", Arial, sans-serif;
font-weight: 500; /* closest to semibold until 600 is loaded */
font-size: 0.875rem;
line-height: 1.2;
}
/* ---------- Glass border (gradient 1px border that respects border-radius) ---------- */
/* border-image ignores border-radius, so we use a masked pseudo-element instead.
Apply `.glass-border` to a `relative` container with `overflow-hidden` off
(the pseudo-element is inset:0 and radius:inherit). */
.glass-border {
border-radius: inherit;
padding: 1px;
/* top-light, bottom-shadow — reads as a raised bevel, not a glass sheen */
background: linear-gradient(180deg, var(--glass-border-from), var(--glass-border-to));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
/* Applied on top of --shadow-elevated/--shadow-resting to animate between them. */
.elevation-transition {
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
}
/* ---------- PWA polish ---------- */
html,
body {
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
.no-select {
-webkit-user-select: none;
user-select: none;
}

View File

@@ -0,0 +1,79 @@
export type Language = "de" | "en"
export interface LocalizedString {
de: string
en: string
}
export type CategoryId =
| "protein"
| "snack"
| "obst"
| "gemuese"
| "fett"
| "kh"
export type TagType = "pick" | "caution"
export type MealType = "full" | "half"
export type TimeSlot = "morning" | "midday" | "afternoon" | "evening"
export type PlanRecipeType = "breakfast" | "snack" | "full" | "recipe"
export type HomeWidgetId = "suggestion" | "knowledge" | "builder"
export type WidgetOrder = HomeWidgetId[]
export interface FoodItem {
id: string
cat: CategoryId
name: LocalizedString
valueInfo: LocalizedString
tag?: TagType
isApprox?: boolean
}
export interface FoodCategory {
id: CategoryId
cls: string
title: LocalizedString
thumb: LocalizedString
items: FoodItem[]
}
/** Condiment / prep ingredient outside the plate categories (CATS). */
export interface ExtraItem {
id: string
name: LocalizedString
}
/** Resolved CATS or EXTRAS entry for recipe `uses` ids. */
export interface CatalogItem {
id: string
name: LocalizedString
source: "cat" | "extra"
cat?: CategoryId
valueInfo?: LocalizedString
tag?: TagType
}
/**
* Curated meal / snack / how-to recipes.
* `uses` references ids from CATS (foods) or EXTRAS.
*/
export interface Recipe {
id: string
type: PlanRecipeType
name: LocalizedString
uses: string[]
cooked?: boolean
text?: LocalizedString
/** Optional flavor / protein twists shown under the base recipe text. */
variations?: LocalizedString[]
}
export interface BuilderHandoffState {
ingredientIds: string[]
source?: "suggestion" | "home"
}