Compare commits

..

2 Commits

Author SHA1 Message Date
Steffi Müller
a0d415b08f Rebuild app IA and polish pantry, chips, and food data.
Introduce the four-tab hubs (Jetzt/Küche/Planen/Wissen), frosted ingredient flyouts with caution reasons, structured nutrition fields with protein sorting, and disabled coming-soon nav tiles.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 15:52:24 +02:00
Steffi Müller
a482bf5b09 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>
2026-08-04 12:22:32 +02:00
73 changed files with 9404 additions and 70 deletions

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
@@ -7,14 +7,17 @@
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="description" content="A simple Hello World PWA with a React frontend and SQLite backend." />
<meta
name="description"
content="Pocket Pascal — decision aid for daily nutrition using Pascals building-block plate system."
/>
<!-- PWA / iOS -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Pocket Pascal" />
<meta name="theme-color" content="#0a0a0a" />
<meta name="theme-color" content="#fafaf7" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<link rel="manifest" href="/manifest.webmanifest" />

View File

@@ -19,6 +19,8 @@
"lucide-react": "^1.28.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-router-dom": "^7.18.2",
"recharts": "^3.10.1",
"shadcn": "^4.16.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3",
@@ -29,6 +31,7 @@
"@types/node": "^24.13.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^6.0.4",
"oxlint": "^1.75.0",
"typescript": "~6.0.2",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,66 +1,96 @@
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 { HowToLibraryScreen } from "@/screens/HowToLibraryScreen"
import { KitchenHubScreen } from "@/screens/KitchenHubScreen"
import { KnowledgeHubScreen } from "@/screens/KnowledgeHubScreen"
import { PantryScreen } from "@/screens/PantryScreen"
import { PlaceholderScreen } from "@/screens/PlaceholderScreen"
import { PlanHubScreen } from "@/screens/PlanHubScreen"
import { PlateScreen } from "@/screens/PlateScreen"
import { RulesScreen } from "@/screens/RulesScreen"
import { SettingsScreen } from "@/screens/SettingsScreen"
import { WeekPlanScreen } from "@/screens/WeekPlanScreen"
import { ui } from "@/i18n/ui"
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>
<LanguageProvider>
<BrowserRouter>
<Routes>
<Route element={<AppShell />}>
<Route index element={<HomeScreen />} />
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl">
{data?.message ?? "Hello, World!"}
</h1>
<Route path="kitchen" element={<KitchenHubScreen />} />
<Route path="kitchen/pantry" element={<PantryScreen />} />
<Route path="kitchen/builder" element={<BuilderScreen />} />
<Route
path="kitchen/stock"
element={
<PlaceholderScreen
backTo="/kitchen"
backLabel={ui.kitchen.back}
title={ui.placeholders.stockTitle}
body={ui.placeholders.stockBody}
/>
}
/>
<p className="text-muted-foreground text-sm">
A minimal installable PWA. React + shadcn/ui on the front, Express +
SQLite on the back.
</p>
<Route path="plan" element={<PlanHubScreen />} />
<Route path="plan/week" element={<WeekPlanScreen />} />
<Route
path="plan/meal-planner"
element={
<PlaceholderScreen
backTo="/plan"
backLabel={ui.planHub.back}
title={ui.placeholders.mealPlannerTitle}
body={ui.placeholders.mealPlannerBody}
/>
}
/>
<Route
path="plan/shopping"
element={
<PlaceholderScreen
backTo="/plan"
backLabel={ui.planHub.back}
title={ui.placeholders.shoppingTitle}
body={ui.placeholders.shoppingBody}
/>
}
/>
<Route
path="plan/recipes"
element={
<PlaceholderScreen
backTo="/plan"
backLabel={ui.planHub.back}
title={ui.placeholders.recipesTitle}
body={ui.placeholders.recipesBody}
/>
}
/>
<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>
<Route path="knowledge" element={<KnowledgeHubScreen />} />
<Route path="knowledge/plate" element={<PlateScreen />} />
<Route path="knowledge/recipes" element={<HowToLibraryScreen />} />
<Route path="knowledge/rules" element={<RulesScreen />} />
<Route path="knowledge/settings" element={<SettingsScreen />} />
<Button onClick={() => void load()} disabled={loading}>
<RefreshCw className={loading ? "animate-spin" : undefined} />
Say it again
</Button>
</div>
</main>
{/* Legacy redirects */}
<Route path="pantry" element={<Navigate to="/kitchen/pantry" replace />} />
<Route path="builder" element={<Navigate to="/kitchen/builder" replace />} />
<Route path="weekplan" element={<Navigate to="/plan/week" replace />} />
<Route path="settings" element={<Navigate to="/knowledge/settings" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</LanguageProvider>
)
}

View File

@@ -0,0 +1,42 @@
import { categoryDotColor } from "@/lib/catalog"
import { cx } from "@/lib/cx"
import type { CategoryId } from "@/types/domain"
const CAT_ICON: Record<CategoryId, string> = {
protein: "🥚",
snack: "🥤",
obst: "🍎",
gemuese: "🥦",
fett: "🥑",
kh: "🌾",
}
/**
* Soft-Chip category marker (Vorrat accordion headers).
* Category hue washed with white — readable emoji, no side stripe on cards.
*/
export function CategorySoftChip({
categoryId,
className,
}: {
categoryId: CategoryId
className?: string
}) {
return (
<span
aria-hidden
className={cx(
"flex h-9 w-9 items-center justify-center rounded-[var(--radius-control)]",
"text-lg leading-none ring-1 ring-[var(--border)]",
className,
)}
style={{
background: categoryDotColor(categoryId),
boxShadow:
"inset 0 0 0 999px color-mix(in srgb, white 72%, transparent)",
}}
>
{CAT_ICON[categoryId]}
</span>
)
}

View File

@@ -0,0 +1,159 @@
import { Popover } from "@base-ui/react/popover"
import { t, tr, ui } from "@/i18n/ui"
import { foodCategoryById } from "@/lib/catalog"
import { categoryChipSurface, chipAriaLabel, chipTagModifiers } from "@/lib/chipStyle"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import type { CatalogItem, Language } from "@/types/domain"
function detailAria(name: string, lang: Language): string {
return t(ui.ingredients.detailAria, lang).replace("{name}", name)
}
/**
* Chip + flyout (Base UI Popover). Tap opens; tap same chip / outside closes.
* Content: valueInfo · caution (if tagged) · category Daumenregel.
* Name is not repeated — the chip already shows it.
*/
export function IngredientChip({
item,
size,
}: {
item: CatalogItem
size: "regular" | "large"
}) {
const { lang } = useLanguage()
const large = size === "large"
const surface = categoryChipSurface(item.cat)
const tagMod = chipTagModifiers(item.tag)
const name = tr(item.name, lang)
const label = chipAriaLabel(
name,
item.tag,
t(ui.pantry.pick, lang),
t(ui.pantry.caution, lang),
)
const category = item.cat ? foodCategoryById(item.cat) : undefined
const valueInfo = item.valueInfo ? tr(item.valueInfo, lang) : null
/** Obst: valueInfo is enough — no category tip in the flyout. */
const showThumb = Boolean(category && item.cat !== "obst")
const thumb = showThumb && category ? tr(category.thumb, lang) : null
const categoryTitle = showThumb && category ? tr(category.title, lang) : null
const showCaution = item.tag === "caution"
const cautionWhy =
showCaution && item.cautionWhy ? tr(item.cautionWhy, lang) : null
const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5"
const chipType = large
? "type-body-emphasis text-[var(--ink)]"
: "type-caption font-bold text-[var(--ink)]"
return (
<Popover.Root>
<Popover.Trigger
type="button"
aria-label={detailAria(label, lang)}
title={item.tag ? label : undefined}
className={cx(
"inline-flex items-center rounded-[var(--radius-pill)]",
"cursor-pointer transition-[filter] active:brightness-[0.98]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
"data-[popup-open]:ring-2 data-[popup-open]:ring-[var(--ink)]/25",
chipType,
chipBox,
tagMod.className,
)}
style={{ ...surface, ...tagMod.style }}
>
<span className={cx("truncate", large ? "max-w-[10rem]" : "max-w-[8rem]")}>
{name}
</span>
</Popover.Trigger>
<Popover.Portal>
{/*
Prefer bottom-end (bottom-right of chip). Collision avoidance flips
when the panel would leave the viewport. No scale/transform — keeps
backdrop-filter working.
*/}
<Popover.Positioner
side="bottom"
align="end"
sideOffset={8}
collisionPadding={12}
className="z-50 outline-none"
>
<Popover.Popup
className={cx(
"relative w-[min(18rem,calc(100vw-2rem))] rounded-[var(--radius-card)] p-3",
"shadow-[var(--shadow-float)]",
"data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
"transition-opacity duration-150 ease-out",
)}
style={{
background:
"color-mix(in srgb, var(--glass-fill) 12%, transparent)",
backdropFilter: "blur(28px) saturate(1.6)",
WebkitBackdropFilter: "blur(28px) saturate(1.6)",
}}
>
<div
aria-hidden
className="pointer-events-none absolute inset-0 rounded-[inherit] opacity-40"
style={{
backgroundImage:
"linear-gradient(145deg, color-mix(in srgb, white 45%, transparent) 0%, transparent 50%)",
}}
/>
<div
aria-hidden
className="glass-border pointer-events-none absolute inset-0 rounded-[inherit]"
/>
{/* Solid ink on glass — ink-soft fails AA over busy blurred chips */}
<div className="relative z-10 [text-shadow:0_0.5px_0_color-mix(in_srgb,white_55%,transparent)]">
<Popover.Title className="sr-only">{name}</Popover.Title>
{valueInfo ? (
<Popover.Description className="type-body-emphasis text-[var(--ink)]">
{valueInfo}
</Popover.Description>
) : null}
{showCaution ? (
<div className={valueInfo ? "mt-2" : undefined}>
<p className="type-caption font-bold text-[var(--signal-warning)]">
{t(ui.pantry.caution, lang)}
</p>
{cautionWhy ? (
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
{cautionWhy}
</p>
) : null}
</div>
) : null}
{thumb ? (
<div
className={cx(
"border-t border-[var(--border)] pt-2.5",
valueInfo || showCaution ? "mt-3" : undefined,
)}
>
<p className="type-label text-[var(--ink-faint)]">
{t(ui.pantry.tip, lang)}
{categoryTitle ? ` · ${categoryTitle}` : null}
</p>
<p className="type-caption mt-1 font-medium text-[var(--ink)]">
{thumb}
</p>
</div>
) : null}
</div>
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
)
}

View File

@@ -0,0 +1,73 @@
import { useState } from "react"
import { IngredientChip } from "@/components/food/IngredientChip"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import type { CatalogItem } from "@/types/domain"
/**
* Ingredient chips — two sizes only:
* - `regular` (default): browse lists (Wochenplan, How-to, Builder, …)
* - `large`: Jetzt suggestion only
*
* Each chip opens a flyout (tap again / outside to close).
*/
export function IngredientChips({
items,
maxVisible,
className,
size = "regular",
}: {
items: CatalogItem[]
maxVisible?: number
className?: string
size?: "regular" | "large"
}) {
const { lang } = useLanguage()
const [expanded, setExpanded] = useState(false)
const large = size === "large"
const overflow =
maxVisible !== undefined ? Math.max(0, items.length - maxVisible) : 0
const visible =
maxVisible !== undefined && !expanded
? items.slice(0, maxVisible)
: items
const chipBox = large ? "min-h-[44px] px-3" : "min-h-[32px] px-2.5"
return (
<ul className={cx("flex flex-wrap", large ? "gap-2" : "gap-1.5", className)}>
{visible.map((item) => (
<li key={item.id} className="inline-flex">
<IngredientChip item={item} size={size} />
</li>
))}
{overflow > 0 ? (
<li>
<button
type="button"
aria-expanded={expanded}
aria-label={
expanded
? t(ui.ingredients.showLess, lang)
: t(ui.ingredients.showMore, lang)
}
onClick={() => setExpanded((open) => !open)}
className={cx(
"inline-flex items-center rounded-[var(--radius-pill)] ring-1 ring-[var(--border)]",
"text-[var(--ink-faint)] cursor-pointer transition-colors",
"hover:text-[var(--ink)] focus-visible:outline-none",
"focus-visible:ring-2 focus-visible:ring-[var(--ink)]",
large ? "type-body-emphasis" : "type-caption font-bold",
chipBox,
)}
style={{ background: "var(--glass-fill)" }}
>
{expanded ? `${overflow}` : `+${overflow}`}
</button>
</li>
) : null}
</ul>
)
}

View File

@@ -0,0 +1,23 @@
import { SuggestionCard } from "@/components/home/SuggestionCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
/** Focus-First Start: only the hunger suggestion — tools live in Küche / Planen. */
export function HomeScreen() {
const { lang } = useLanguage()
return (
<div className="flex flex-col gap-4">
<header>
<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>
</header>
<section aria-label={t(ui.home.title, lang)}>
<SuggestionCard />
</section>
</div>
)
}

View File

@@ -0,0 +1,131 @@
import type { ReactNode } from "react"
import { useNavigate } from "react-router-dom"
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 { LocalizedString } from "@/types/domain"
export interface NavCardProps {
title: string
body: string
icon: ReactNode
gradient?: string
/** Required when interactive; ignored when `disabled`. */
onNavigate?: () => void
className?: string
size?: "default" | "featured"
/** Status chip, e.g. Demnächst — typically paired with `disabled`. */
badge?: string
/** Coming-soon / unavailable: not clickable, no navigation. */
disabled?: boolean
}
/** Navigation card — noun-phrase body, ≥44px touch target when interactive. */
export function NavCard({
title,
body,
icon,
gradient = "var(--gradient-primary)",
onNavigate,
className,
size = "default",
badge,
disabled = false,
}: NavCardProps) {
const featured = size === "featured"
const interactive = !disabled && Boolean(onNavigate)
return (
<GlassCard
variant={interactive ? "interactive" : "default"}
aria-disabled={disabled || undefined}
className={cx(
featured ? "p-5" : "p-4",
"min-h-[44px]",
disabled && "opacity-55",
className,
)}
onClick={interactive ? onNavigate : undefined}
onKeyDown={
interactive
? (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault()
onNavigate?.()
}
}
: undefined
}
>
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<span
className={cx(
"flex items-center justify-center rounded-[var(--radius-control)] text-[var(--ink)]",
"[&_svg]:text-[var(--ink)]",
featured ? "h-12 w-12" : "h-10 w-10",
)}
style={{ backgroundImage: gradient }}
>
{icon}
</span>
{badge ? (
<span className="type-caption rounded-[var(--radius-pill)] px-2 py-1 font-bold text-[var(--ink-faint)] ring-1 ring-[var(--border)]">
{badge}
</span>
) : null}
</div>
<div>
<h3
className={cx(
"text-[var(--ink)]",
featured ? "type-title" : "type-title-sm",
)}
>
{title}
</h3>
<p className="type-body mt-1 text-[var(--ink-soft)]">{body}</p>
</div>
</div>
</GlassCard>
)
}
export function HubBackLink({
to,
label,
}: {
to: string
label: LocalizedString
}) {
const { lang } = useLanguage()
const navigate = useNavigate()
return (
<button
type="button"
onClick={() => navigate(to)}
className={cx(
"type-caption mb-1 inline-flex min-h-[44px] items-center gap-1 font-bold text-[var(--ink-soft)]",
"active:brightness-95",
)}
>
<span aria-hidden></span>
{t(label, lang)}
</button>
)
}
export function KnowledgeBackLink() {
return <HubBackLink to="/knowledge" label={ui.knowledge.back} />
}
export function KitchenBackLink() {
return <HubBackLink to="/kitchen" label={ui.kitchen.back} />
}
export function PlanBackLink() {
return <HubBackLink to="/plan" label={ui.planHub.back} />
}

View File

@@ -0,0 +1,136 @@
import { useMemo, useState } from "react"
import { RefreshCw } from "lucide-react"
import { IngredientChips } from "@/components/food/IngredientChips"
import { Button } from "@/components/ui-pp/Button"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
import { t, tr, ui } from "@/i18n/ui"
import { resolveRecipeUses } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import { pickSuggestion } from "@/lib/suggestion"
import { formatClock, getTimeContext } from "@/lib/timeContext"
import type { Effort, MealTime, MealType, Recipe } from "@/types/domain"
const EFFORT_OPTIONS: Effort[] = ["none", "quick", "cook"]
function effortLabel(effort: Effort, lang: "de" | "en"): string {
if (effort === "none") return t(ui.suggestion.effortNone, lang)
if (effort === "quick") return t(ui.suggestion.effortQuick, lang)
return t(ui.suggestion.effortCook, lang)
}
function mealTimeLabel(mealTime: MealTime, lang: "de" | "en"): string {
return t(ui.suggestion[mealTime], lang)
}
function plateLabel(mealType: MealType, lang: "de" | "en"): string {
return mealType === "full"
? t(ui.suggestion.plateFull, lang)
: t(ui.suggestion.plateHalf, lang)
}
export function SuggestionCard() {
const { lang } = useLanguage()
const context = useMemo(() => getTimeContext(), [])
const [effort, setEffort] = useState<Effort | null>(null)
const [recipe, setRecipe] = useState<Recipe | null>(() =>
pickSuggestion(context.mealTime, null),
)
const ingredients = recipe ? resolveRecipeUses(recipe) : []
const clock = formatClock(context.hour, context.minute)
const plateType = recipe?.type ?? context.mealType
const eyebrow = `${mealTimeLabel(context.mealTime, lang)} · ${plateLabel(plateType, lang)}`
function applyEffort(next: Effort | null) {
setEffort(next)
setRecipe(pickSuggestion(context.mealTime, next, recipe?.id))
}
function toggleEffort(value: Effort) {
applyEffort(effort === value ? null : value)
}
function reroll() {
setRecipe(pickSuggestion(context.mealTime, effort, recipe?.id ?? undefined))
}
return (
<GlassCard className="p-5">
<div className="flex flex-col gap-4">
<div className="flex gap-3">
<TimeIconBadge
hour={context.hour}
mealTime={context.mealTime}
aria-label={eyebrow}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<p className="type-label uppercase tracking-wide text-[var(--ink-soft)]">
{eyebrow}
</p>
<p className="type-caption text-[var(--ink-faint)]" aria-hidden>
{clock}
{lang === "de" ? " Uhr" : ""}
</p>
</div>
{recipe ? (
<h2 className="type-title text-[var(--ink)]">
{tr(recipe.name, lang)}
</h2>
) : (
<p className="type-body text-[var(--ink-soft)]">
{t(ui.suggestion.emptyFilter, lang)}
</p>
)}
</div>
</div>
{ingredients.length > 0 ? (
<IngredientChips items={ingredients} size="large" />
) : null}
<div>
<p className="type-label mb-2 text-[var(--ink-faint)]">
{t(ui.suggestion.effortLabel, lang)}
</p>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label={t(ui.suggestion.effortLabel, lang)}
>
{EFFORT_OPTIONS.map((option) => {
const active = effort === option
return (
<button
key={option}
type="button"
aria-pressed={active}
onClick={() => toggleEffort(option)}
className={cx(
"elevation-transition inline-flex min-h-[44px] items-center rounded-[var(--radius-pill)] px-3",
"type-caption font-bold text-[var(--ink)]",
"ring-1 ring-[var(--border)]",
"active:translate-y-px active:brightness-95",
active && "ring-2 ring-[var(--ui-accent)]/40",
)}
style={{ background: "var(--glass-fill)" }}
>
{effortLabel(option, lang)}
</button>
)
})}
</div>
</div>
<Button variant="secondary" onClick={reroll}>
<RefreshCw className="h-4 w-4" aria-hidden />
{t(ui.suggestion.reroll, lang)}
</Button>
</div>
</GlassCard>
)
}

View File

@@ -0,0 +1,58 @@
import { Outlet, useLocation, useNavigate } from "react-router-dom"
import { BookOpen, CalendarDays, CookingPot, Sparkles } from "lucide-react"
import { BottomNav } from "@/components/ui-pp/BottomNav"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
const ROUTES = [
{ key: "now", path: "/", icon: Sparkles, labelKey: "now" as const },
{ key: "kitchen", path: "/kitchen", icon: CookingPot, labelKey: "kitchen" as const },
{ key: "plan", path: "/plan", icon: CalendarDays, labelKey: "plan" as const },
{
key: "knowledge",
path: "/knowledge",
icon: BookOpen,
labelKey: "knowledge" 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,214 @@
import { useMemo } from "react"
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts"
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[]
/** Mobile-first default. */
size?: number
selectedId?: string | null
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) }
}
/**
* Ceramic dining plate with detached, rounded pie wedges (Recharts).
* padAngle ≈ ceramic gaps; cornerRadius softens each Baustein tile.
*/
export function DiningPlate({
segments,
size = 240,
selectedId = null,
onSelect,
className,
"aria-label": ariaLabel,
}: DiningPlateProps) {
const rimPad = size * 0.14
const foodR = size / 2 - rimPad
const center = size / 2
const chartData = useMemo(
() =>
segments.map((segment) => ({
id: segment.id,
value: segment.weight,
color: segment.color,
label: segment.label,
})),
[segments],
)
/** Mid-angles for rim dots / floating pill (Recharts starts at +90° / 12 o'clock). */
const markers = useMemo(() => {
const total = segments.reduce((sum, s) => sum + s.weight, 0) || 1
const padAngle = 5
const padTotal = padAngle * segments.length
const usable = 360 - padTotal
let angle = 0
return segments.map((segment) => {
const sweep = (segment.weight / total) * usable
const start = angle + padAngle / 2
const end = angle + padAngle / 2 + sweep
const mid = (start + end) / 2
angle += sweep + padAngle
return {
id: segment.id,
label: segment.label,
color: segment.color,
marker: polar(center, center, foodR + rimPad * 0.48, mid),
pill: polar(center, center, foodR + rimPad * 1.45, mid),
}
})
}, [segments, center, foodR, rimPad])
return (
<div
className={cx("relative mx-auto select-none overflow-visible", className)}
style={{ width: size, height: size, maxWidth: "100%" }}
role="img"
aria-label={ariaLabel}
>
{/* 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) 48%, var(--plate-well-edge) 100%)",
boxShadow: `
0 6px 18px rgba(25, 24, 0, 0.1),
inset 5px 6px 12px rgba(255, 255, 255, 0.9),
inset -6px -7px 14px rgba(25, 24, 0, 0.11),
inset 0 0 0 1px rgba(25, 24, 0, 0.05)
`,
}}
/>
{/* Recessed well — ceramic shows through wedge gaps */}
<div
className="absolute overflow-hidden rounded-full"
style={{
inset: rimPad,
background:
"radial-gradient(circle at 50% 42%, #fbfbf7 0%, #ecece4 100%)",
boxShadow: "inset 0 2px 10px rgba(25, 24, 0, 0.07)",
}}
>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="label"
cx="50%"
cy="50%"
innerRadius={0}
outerRadius="98%"
startAngle={90}
endAngle={-270}
paddingAngle={5}
cornerRadius={10}
stroke="none"
isAnimationActive={false}
onClick={(_, index) => {
const segment = chartData[index]
if (segment) onSelect?.(segment.id)
}}
>
{chartData.map((entry) => (
<Cell
key={entry.id}
fill={entry.color}
fillOpacity={
selectedId && selectedId !== entry.id ? 0.72 : 0.96
}
className={cx(
"outline-none",
onSelect && "cursor-pointer focus:outline-none",
)}
style={{ outline: "none" }}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
{/* Rim markers */}
{markers.map((marker) => {
const selected = selectedId === marker.id
return (
<button
key={`marker-${marker.id}`}
type="button"
aria-label={marker.label}
aria-pressed={selected}
onClick={() => onSelect?.(marker.id)}
className={cx(
"absolute z-20 -translate-x-1/2 -translate-y-1/2",
"flex min-h-[44px] min-w-[44px] items-center justify-center",
"no-select [-webkit-tap-highlight-color:transparent]",
)}
style={{ left: marker.marker.x, top: marker.marker.y }}
>
<span
className={cx(
"elevation-transition block rounded-full",
selected ? "h-3.5 w-3.5" : "h-2.5 w-2.5",
)}
style={{
background: marker.color,
boxShadow: selected
? "0 0 0 3px rgba(255,255,255,0.95), 0 0 0 5px rgba(25,24,0,0.2), 0 2px 6px rgba(25,24,0,0.18)"
: "0 0 0 2px rgba(255,255,255,0.95), 0 0 0 3px rgba(25,24,0,0.18)",
}}
/>
</button>
)
})}
{/* Floating label pill for selection */}
{markers.map((marker) => {
if (selectedId !== marker.id) return null
return (
<div
key={`pill-${marker.id}`}
className={cx(
"pointer-events-none absolute z-30 -translate-x-1/2 -translate-y-1/2",
"type-caption whitespace-nowrap rounded-[var(--radius-pill)] px-3 py-1.5 font-bold text-[var(--ink)]",
"shadow-[var(--shadow-float)] ring-1 ring-[var(--border)]",
)}
style={{
left: marker.pill.x,
top: marker.pill.y,
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
>
<span
className="mr-1.5 inline-block h-2 w-2 rounded-full align-middle"
style={{ background: marker.color }}
aria-hidden
/>
{marker.label}
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,166 @@
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 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,94 @@
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.
* Active state uses --ui-accent (non-semantic chrome), not category colors.
*/
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(--ui-accent)]"
: "text-[var(--ink-soft)]",
)}
>
{item.icon}
</span>
<span
className={cx(
"type-label normal-case tracking-wide",
item.active
? "text-[var(--ui-accent)]"
: "text-[var(--ink-soft)]",
)}
>
{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,84 @@
import type { LucideIcon } from "lucide-react"
import { cx } from "@/lib/cx"
import {
DAYLIGHT_GRADIENT,
daylightIconPlate,
daylightPeriodFromHour,
daylightPeriodFromMealTime,
mealTimeIcon,
periodFallbackIcon,
type DaylightPeriod,
} from "@/lib/daylight"
import type { MealTime } from "@/types/domain"
export type { DaylightPeriod }
export interface TimeIconBadgeProps {
icon?: LucideIcon
/** Explicit period wins over hour / mealTime. */
period?: DaylightPeriod
/**
* Wall-clock hour for live “now” gradient.
* When set (without `period`), drives the sky gradient — even if `mealTime` is set for the icon.
*/
hour?: number
/** Section meal slot — drives icon; drives gradient only when `hour` is omitted. */
mealTime?: MealTime
className?: string
"aria-hidden"?: boolean
"aria-label"?: string
}
/**
* Rounded gradient badge for time-of-day / meal-section headers.
* - Suggestion (now): pass `hour` + `mealTime` (sky from hour, icon from meal).
* - Wochenplan sections: pass `mealTime` only (stable per section).
*/
export function TimeIconBadge({
icon,
period: periodProp,
hour,
mealTime,
className,
"aria-hidden": ariaHidden,
"aria-label": ariaLabel,
}: TimeIconBadgeProps) {
const period: DaylightPeriod =
periodProp ??
(hour !== undefined
? daylightPeriodFromHour(hour)
: mealTime
? daylightPeriodFromMealTime(mealTime)
: daylightPeriodFromHour(new Date().getHours()))
const Icon =
icon ?? (mealTime ? mealTimeIcon(mealTime) : periodFallbackIcon(period))
const { plate, icon: iconColor } = daylightIconPlate(period)
return (
<span
aria-hidden={ariaHidden}
aria-label={ariaLabel}
className={cx(
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--radius-control)]",
"shadow-[var(--shadow-resting)]",
className,
)}
style={{ backgroundImage: DAYLIGHT_GRADIENT[period] }}
>
<span
className="inline-flex h-7 w-7 items-center justify-center rounded-[10px]"
style={{ background: plate }}
>
<Icon
className="h-4 w-4"
strokeWidth={2.4}
color={iconColor}
aria-hidden
/>
</span>
</span>
)
}

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" } }
]

1332
client/src/data/foods.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
[
{
"id": "rec_protein_shake",
"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",
"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",
"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",
"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,127 @@
[
{
"id": "skyr_oats_blueberry",
"type": "half",
"times": ["breakfast"],
"effort": "none",
"name": {
"de": "Skyr + Haferflocken + Blaubeeren",
"en": "Skyr + oats + blueberries"
},
"uses": ["skyr", "oats", "blueberry"]
},
{
"id": "eggs_tomato",
"type": "half",
"times": ["breakfast"],
"effort": "quick",
"name": { "de": "2 Eier + Tomate", "en": "2 eggs + tomato" },
"uses": ["egg", "tomato"]
},
{
"id": "quark_pomegranate",
"type": "half",
"times": ["breakfast", "snack"],
"effort": "none",
"name": {
"de": "Magerquark + Granatapfel",
"en": "Low-fat quark + pomegranate"
},
"uses": ["low_fat_quark", "pomegranate"]
},
{
"id": "shake_banana",
"type": "half",
"times": ["breakfast", "snack"],
"effort": "none",
"name": {
"de": "Proteinshake + Banane",
"en": "Protein shake + banana"
},
"uses": ["protein_shake", "banana"]
},
{
"id": "skyr_blueberry",
"type": "half",
"times": ["snack"],
"effort": "none",
"name": { "de": "Skyr + Blaubeeren", "en": "Skyr + blueberries" },
"uses": ["skyr", "blueberry"]
},
{
"id": "cottage_cucumber",
"type": "half",
"times": ["snack"],
"effort": "none",
"name": {
"de": "Hüttenkäse + Gurke",
"en": "Cottage cheese + cucumber"
},
"uses": ["cottage_cheese", "cucumber"]
},
{
"id": "protein_pudding_snack",
"type": "half",
"times": ["snack"],
"effort": "none",
"name": { "de": "Proteinpudding", "en": "Protein pudding" },
"uses": ["protein_pudding"]
},
{
"id": "chicken_warm_plate",
"type": "full",
"times": ["lunch", "dinner"],
"effort": "cook",
"name": {
"de": "Hühnerbrust warm mit Gemüse",
"en": "Warm chicken breast with veg"
},
"uses": ["chicken_breast", "carrot", "spinach", "linseed_oil", "red_lentils"]
},
{
"id": "cold_platter",
"type": "full",
"times": ["lunch", "dinner"],
"effort": "quick",
"name": { "de": "Kalte Brotzeit", "en": "Cold platter" },
"uses": ["feta", "lambs_lettuce", "walnuts", "spelt_pasta"]
},
{
"id": "tofu_bowl",
"type": "full",
"times": ["lunch", "dinner"],
"effort": "none",
"name": { "de": "Räuchertofu-Bowl", "en": "Smoked tofu bowl" },
"uses": ["smoked_tofu_cubes", "spinach", "avocado", "french_lentils"]
},
{
"id": "leftovers_plate",
"type": "full",
"times": ["lunch", "dinner"],
"effort": "none",
"name": { "de": "Resteteller kalt", "en": "Cold leftovers plate" },
"uses": ["chicken_breast", "carrot", "walnuts", "red_lentils"]
},
{
"id": "tempeh_stirfry_plate",
"type": "full",
"times": ["lunch", "dinner"],
"effort": "cook",
"name": {
"de": "Tempeh-Gemüsepfanne",
"en": "Tempeh vegetable stir-fry"
},
"uses": ["tempeh", "bell_pepper", "zucchini", "sesame_oil", "quinoa"]
},
{
"id": "chicken_salad_plate",
"type": "full",
"times": ["lunch", "dinner"],
"effort": "quick",
"name": {
"de": "Hühnersalat mit Kichererbsen",
"en": "Chicken salad with chickpeas"
},
"uses": ["chicken_breast", "lambs_lettuce", "avocado", "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 & Gemüse",
"en": "Fruit & Veggie"
},
"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)."
}
}
]

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

@@ -0,0 +1,248 @@
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: {
now: { de: "Jetzt", en: "Now" },
kitchen: { de: "Küche", en: "Kitchen" },
plan: { de: "Planen", en: "Plan" },
knowledge: { de: "Wissen", en: "Knowledge" },
},
home: {
title: { de: "Was esse ich jetzt?", en: "What do I eat now?" },
},
kitchen: {
title: { de: "Küche", en: "Kitchen" },
intro: {
de: "Vorrat, Bestand und Teller bauen",
en: "Pantry, stock and plate building",
},
back: { de: "Küche", en: "Kitchen" },
pantryTitle: { de: "Vorrat", en: "Pantry" },
pantryBody: {
de: "Lebensmittel und Markierungen",
en: "Foods and markers",
},
stockTitle: { de: "Bestand", en: "In stock" },
stockBody: {
de: "Was zu Hause ist",
en: "What's at home",
},
builderTitle: { de: "Teller-Builder", en: "Plate builder" },
builderBody: {
de: "Kombination und Regel-Check",
en: "Combination and rule check",
},
},
planHub: {
title: { de: "Planen", en: "Plan" },
intro: {
de: "Woche, Einkauf und eigene Rezepte",
en: "Week, shopping and your recipes",
},
back: { de: "Planen", en: "Plan" },
weekTitle: { de: "Wochenplan", en: "Weekly plan" },
weekBody: {
de: "Frühstück, Mittag, Snack, Abend",
en: "Breakfast, lunch, snack, dinner",
},
mealPlannerTitle: { de: "Meal Planner", en: "Meal planner" },
mealPlannerBody: {
de: "Tage und Mahlzeiten zuordnen",
en: "Assign meals to days",
},
shoppingTitle: { de: "Einkaufsliste", en: "Shopping list" },
shoppingBody: {
de: "Abhakbar im Laden",
en: "Checkable in the shop",
},
recipesTitle: { de: "Rezepte", en: "Recipes" },
recipesBody: {
de: "Eigene Rezepte und Import",
en: "Your recipes and import",
},
},
placeholders: {
badge: { de: "Demnächst", en: "Coming soon" },
stockTitle: { de: "Bestand", en: "In stock" },
stockBody: {
de: "Erfasst, was zu Hause ist — Grundlage für Vorschläge aus dem Vorrat.",
en: "Tracks what's at home — basis for stock-aware suggestions.",
},
shoppingTitle: { de: "Einkaufsliste", en: "Shopping list" },
shoppingBody: {
de: "Ausgewogene Liste nach Kategorien, abhakbar im Laden.",
en: "Balanced list by category, checkable while shopping.",
},
mealPlannerTitle: { de: "Meal Planner", en: "Meal planner" },
mealPlannerBody: {
de: "Mahlzeiten auf Wochentage legen — bewusst später als der Wochenplan.",
en: "Place meals on weekdays — intentionally later than the weekly browse plan.",
},
recipesTitle: { de: "Rezepte", en: "Recipes" },
recipesBody: {
de: "Eigene Rezepte speichern. Später: Link aus Social Media teilen — Zutaten, Schritte und Bild extrahieren.",
en: "Save your recipes. Later: share a social link — extract ingredients, steps and thumbnail.",
},
},
suggestion: {
breakfast: { de: "Frühstück", en: "Breakfast" },
lunch: { de: "Mittag", en: "Lunch" },
snack: { de: "Snack", en: "Snack" },
dinner: { de: "Abend", en: "Dinner" },
plateFull: { de: "Ganzer Teller", en: "Full plate" },
plateHalf: { de: "Halber Teller", en: "Half plate" },
effortLabel: { de: "Wie viel Zeit?", en: "How much time?" },
effortNone: { de: "Keine Zeit", en: "No time" },
effortQuick: { de: "15 Min", en: "15 min" },
effortCook: { de: "Kann kochen", en: "Can cook" },
reroll: { de: "Neuer Vorschlag", en: "New suggestion" },
emptyFilter: {
de: "Mit dieser Zeit passt gerade nichts — Filter lockern?",
en: "Nothing fits this time filter — loosen it?",
},
},
knowledge: {
title: { de: "Wissen", en: "Knowledge" },
intro: {
de: "Regeln, Rezepte und Etikett-Checks",
en: "Rules, recipes and label checks",
},
plateTitle: { de: "Mein Teller", en: "My Plate" },
plateBody: {
de: "Ganze und halbe Mahlzeit",
en: "Full and half meals",
},
recipesTitle: { de: "Rezept-Bibliothek", en: "Recipe library" },
recipesBody: {
de: "Zubereitung und Grundrezepte",
en: "Prep steps and base recipes",
},
rulesTitle: { de: "Daumenregeln", en: "Rules of thumb" },
rulesBody: {
de: "Etikett-Check pro Kategorie",
en: "Label check per category",
},
settingsTitle: { de: "Einstellungen", en: "Settings" },
settingsBody: {
de: "Sprache und App-Optionen",
en: "Language and app options",
},
back: { de: "Wissen", en: "Knowledge" },
},
weekplan: {
title: { de: "Wochenplan", en: "Weekly plan" },
intro: {
de: "Browsebare Inspiration für die Woche",
en: "Browsable inspiration for the week",
},
breakfast: { de: "Frühstück", en: "Breakfast" },
lunch: { de: "Mittag", en: "Lunch" },
snack: { de: "Snack", en: "Snack" },
dinner: { de: "Abend", en: "Dinner" },
effortNone: { de: "ohne Kochen", en: "no cook" },
effortQuick: { de: "15 Min", en: "15 min" },
effortCook: { de: "kochen", en: "cook" },
},
ingredients: {
showMore: { de: "Weitere Zutaten anzeigen", en: "Show more ingredients" },
showLess: { de: "Weniger anzeigen", en: "Show less" },
detailAria: {
de: "Details zu {name}",
en: "Details for {name}",
},
},
howto: {
title: { de: "Rezept-Bibliothek", en: "Recipe library" },
intro: {
de: "Zubereitungsanleitungen und Grundrezepte",
en: "Prep instructions and base recipes",
},
uses: { de: "Zutaten", en: "Ingredients" },
variations: { de: "Variationen", en: "Variations" },
},
pantry: {
title: { de: "Küche", en: "Kitchen" },
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" },
per100gHint: {
de: "Angaben pro 100 g",
en: "Values per 100 g",
},
legendProtein: { de: "Protein", en: "Protein" },
legendProduce: { de: "Obst & Gemüse", en: "Fruit & veg" },
legendFat: { de: "Fett", en: "Fat" },
legendBerry: { de: "Kohlenhydrate", en: "Carbs" },
},
rules: {
title: { de: "Daumenregeln", en: "Rules of thumb" },
intro: {
de: "Etikett-Check pro Baustein-Kategorie",
en: "Label check per building-block category",
},
},
plate: {
title: { de: "Mein Teller", en: "My Plate" },
subtitle: {
de: "Protein, Obst & Gemüse, Fett, Kohlenhydrate",
en: "Protein, fruit & veggie, fat, 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.",
},
},
stubs: {
builderTitle: { de: "Teller-Builder", en: "Plate builder" },
builderBody: {
de: "Phase-1-Platzhalter — Chip-Auswahl & Bewertung folgen",
en: "Phase 1 stub — chip selection and scoring next",
},
builderHandoff: {
de: "Übernommen aus dem Vorschlag",
en: "Handed off from suggestion",
},
builderMissing: { de: "Fehlt noch", en: "Still missing" },
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;
}
}

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

@@ -0,0 +1,240 @@
import type {
CategoryId,
CatalogItem,
ExtraItem,
FoodCategory,
FoodItem,
HowToRecipe,
LocalizedString,
MealTime,
NutritionBasis,
Recipe,
TagType,
} from "@/types/domain"
import extrasRaw from "@/data/extras.json"
import foodsRaw from "@/data/foods.json"
import howtosRaw from "@/data/howtos.json"
import recipesRaw from "@/data/recipes.json"
import stepsRaw from "@/data/steps.json"
interface RawFoodItem {
id: string
name: LocalizedString
v: LocalizedString
basis?: NutritionBasis
kcal?: number
proteinG?: number
f?: TagType
approx?: boolean
note?: LocalizedString
cautionWhy?: LocalizedString
}
interface RawFoodCategory {
id: CategoryId
cls: string
title: LocalizedString
thumb: LocalizedString
items: RawFoodItem[]
sub?: {
title: LocalizedString
items: RawFoodItem[]
}
}
export interface PlateStep {
n: number
cls: string
title: LocalizedString
qty: LocalizedString
text: LocalizedString
}
function formatProteinG(n: number, lang: "de" | "en"): string {
if (Number.isInteger(n)) return String(n)
return lang === "de" ? String(n).replace(".", ",") : String(n)
}
/** Build display valueInfo from structured macros when present. */
export function formatFoodValueInfo(
item: Pick<RawFoodItem, "kcal" | "proteinG" | "approx" | "note" | "v">,
): LocalizedString {
if (item.kcal != null && item.proteinG != null) {
const prefix = item.approx ? "≈" : ""
const noteDe = item.note?.de ? ` ${item.note.de}` : ""
const noteEn = item.note?.en ? ` ${item.note.en}` : ""
return {
de: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "de")} g${noteDe}`,
en: `${prefix}${item.kcal} kcal · ${formatProteinG(item.proteinG, "en")} g${noteEn}`,
}
}
return item.v
}
function mapItem(cat: CategoryId, item: RawFoodItem): FoodItem {
const basis: NutritionBasis =
item.basis ??
(cat === "snack" ? "perServing" : item.id === "egg" ? "perUnit" : "per100g")
return {
id: item.id,
cat,
name: item.name,
valueInfo: formatFoodValueInfo(item),
basis,
kcal: item.kcal,
proteinG: item.proteinG,
tag: item.f,
cautionWhy: item.cautionWhy,
isApprox: item.approx,
}
}
function mapCategory(category: RawFoodCategory): FoodCategory {
const items = [
...category.items.map((item) => mapItem(category.id, item)),
...(category.sub?.items.map((item) => mapItem(category.id, item)) ?? []),
]
return {
id: category.id,
cls: category.cls,
title: category.title,
thumb: category.thumb,
items,
}
}
/**
* Sort: protein high→low within preferred basis first, then other bases,
* then AZ. Preferred basis is switchable later (perServing / perUnit views).
*/
export function sortFoodItems(
items: FoodItem[],
lang: "de" | "en",
preferredBasis: NutritionBasis = "per100g",
): FoodItem[] {
function rank(item: FoodItem): number {
if (item.proteinG != null && item.basis === preferredBasis) return 0
if (item.proteinG != null) return 1
return 2
}
return [...items].sort((a, b) => {
const rankDiff = rank(a) - rank(b)
if (rankDiff !== 0) return rankDiff
if (a.proteinG != null && b.proteinG != null && a.proteinG !== b.proteinG) {
return b.proteinG - a.proteinG
}
return a.name[lang].localeCompare(b.name[lang], lang)
})
}
export const foodCategories: FoodCategory[] = (foodsRaw as RawFoodCategory[]).map(
mapCategory,
)
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]),
)
/** Plate suggestions (RECIPES). */
export const recipes: Recipe[] = recipesRaw as Recipe[]
/** Prep / how-to instructions — not plate suggestions. */
export const howtos: HowToRecipe[] = howtosRaw as HowToRecipe[]
export const plateSteps: PlateStep[] = stepsRaw as PlateStep[]
export const MEAL_TIME_ORDER: MealTime[] = [
"breakfast",
"lunch",
"snack",
"dinner",
]
export function foodsInCategory(cat: CategoryId): FoodItem[] {
return foodCategories.find((category) => category.id === cat)?.items ?? []
}
export function foodCategoryById(cat: CategoryId): FoodCategory | undefined {
return foodCategories.find((category) => category.id === cat)
}
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,
cautionWhy: food.cautionWhy,
}
}
const extra = extraById[id]
if (extra) {
return {
id: extra.id,
name: extra.name,
source: "extra",
}
}
return null
}
export function resolveRecipeUses(
recipe: Pick<Recipe, "uses"> | Pick<HowToRecipe, "uses">,
): CatalogItem[] {
return recipe.uses
.map((id) => resolveCatalogId(id))
.filter((item): item is CatalogItem => Boolean(item))
}
export function recipesForMealTime(time: MealTime): Recipe[] {
return recipes.filter((recipe) => recipe.times.includes(time))
}
/** Category color token for ingredient chips / plate segments. */
export function categoryDotColor(cat: CategoryId | undefined): string {
switch (cat) {
case "protein":
case "snack":
return "var(--cat-protein)"
case "obst":
case "gemuese":
return "var(--cat-produce)"
case "fett":
return "var(--cat-fat)"
case "kh":
return "var(--cat-berry)"
default:
return "var(--ink-faint)"
}
}
/** Four plate slots for completeness scan (snack → protein). */
export type PlateSlot = "protein" | "produce" | "fat" | "berry"
export function plateSlotForCategory(cat: CategoryId): PlateSlot {
if (cat === "protein" || cat === "snack") return "protein"
if (cat === "obst" || cat === "gemuese") return "produce"
if (cat === "fett") return "fat"
return "berry"
}
export const PLATE_SLOT_COLOR: Record<PlateSlot, string> = {
protein: "var(--cat-protein)",
produce: "var(--cat-produce)",
fat: "var(--cat-fat)",
berry: "var(--cat-berry)",
}

View File

@@ -0,0 +1,54 @@
import type { CSSProperties } from "react"
import { categoryDotColor } from "@/lib/catalog"
import type { CategoryId, TagType } from "@/types/domain"
/**
* C1: Category = soft tint only (no solid border).
* C2: caution = dashed warning border. No pick ring on chips.
*/
export function categoryChipSurface(cat: CategoryId | undefined): CSSProperties {
if (!cat) {
return {
background: "var(--glass-fill)",
borderWidth: 0,
borderStyle: "none",
}
}
const token = categoryDotColor(cat)
return {
background: `color-mix(in srgb, ${token} 15%, white)`,
borderWidth: 0,
borderStyle: "none",
}
}
export function chipTagModifiers(tag: TagType | undefined): {
className: string
style: CSSProperties
} {
if (tag === "caution") {
return {
className: "",
style: {
borderWidth: 1.5,
borderStyle: "dashed",
borderColor: "var(--signal-warning)",
},
}
}
return { className: "", style: {} }
}
/** Screen-reader label when pick/caution is ring/border-only. */
export function chipAriaLabel(
name: string,
tag: TagType | undefined,
pickLabel: string,
cautionLabel: string,
): string {
if (tag === "pick") return `${name}${pickLabel}`
if (tag === "caution") return `${name}${cautionLabel}`
return name
}

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,76 @@
import type { LucideIcon } from "lucide-react"
import { Coffee, Moon, Sparkles, Sun, Utensils } from "lucide-react"
import type { MealTime } from "@/types/domain"
export type DaylightPeriod = "morning" | "midday" | "evening" | "night"
export const DAYLIGHT_GRADIENT: Record<DaylightPeriod, string> = {
/* Stops tuned so ink (morning/midday) or white (evening/night) clear AA on UI icons */
morning: "linear-gradient(135deg, #fbbf24 0%, #fb923c 100%)",
midday: "linear-gradient(135deg, #7dd3fc 0%, #93c5fd 50%, #fde68a 100%)",
evening: "linear-gradient(135deg, #be123c 0%, #6b21a8 55%, #b45309 100%)",
night: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 55%, #4c1d95 100%)",
}
/** Icon sits on a frosted plate so thin strokes never land on mid-tone gradient bands. */
export function daylightIconPlate(period: DaylightPeriod): {
plate: string
icon: string
} {
if (period === "evening" || period === "night") {
return {
plate: "color-mix(in srgb, #000000 40%, transparent)",
icon: "#ffffff",
}
}
return {
plate: "color-mix(in srgb, #ffffff 88%, transparent)",
icon: "var(--ink)",
}
}
/** Wall-clock daylight band (Suggestion “now”). */
export function daylightPeriodFromHour(hour: number): DaylightPeriod {
if (hour >= 6 && hour < 11) return "morning"
if (hour >= 11 && hour < 16) return "midday"
if (hour >= 16 && hour < 22) return "evening"
return "night"
}
/**
* Section identity for Wochenplan — NOT wall clock.
* Clock-based fill would paint all four sections identically at any moment.
*/
export function daylightPeriodFromMealTime(mealTime: MealTime): DaylightPeriod {
switch (mealTime) {
case "breakfast":
return "morning"
case "lunch":
return "midday"
case "snack":
return "evening"
case "dinner":
return "night"
}
}
export function mealTimeIcon(mealTime: MealTime): LucideIcon {
switch (mealTime) {
case "breakfast":
return Coffee
case "lunch":
return Utensils
case "snack":
return Sparkles
case "dinner":
return Moon
}
}
export function periodFallbackIcon(period: DaylightPeriod): LucideIcon {
if (period === "night" || period === "evening") return Moon
if (period === "morning") return Coffee
return Sun
}

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
}

10
client/src/lib/signals.ts Normal file
View File

@@ -0,0 +1,10 @@
import { t, ui } from "@/i18n/ui"
import type { Language, TagType } from "@/types/domain"
export function signalGlyphChar(tag: TagType): string {
return tag === "pick" ? "★" : "!"
}
export function signalGlyphLabel(tag: TagType, lang: Language): string {
return tag === "pick" ? t(ui.pantry.pick, lang) : t(ui.pantry.caution, lang)
}

View File

@@ -0,0 +1,32 @@
import { recipesForMealTime } from "@/lib/catalog"
import type { Effort, MealTime, Recipe } from "@/types/domain"
function pickRandom<T>(items: T[]): T | null {
if (items.length === 0) return null
return items[Math.floor(Math.random() * items.length)] ?? null
}
export function filterRecipes(
mealTime: MealTime,
effort: Effort | null,
): Recipe[] {
const byTime = recipesForMealTime(mealTime)
if (!effort) return byTime
return byTime.filter((recipe) => recipe.effort === effort)
}
/**
* Pick a plate suggestion for the current meal time.
* When `avoidId` is set, prefer a different recipe (falls back if only one match).
*/
export function pickSuggestion(
mealTime: MealTime,
effort: Effort | null,
avoidId?: string,
): Recipe | null {
const pool = filterRecipes(mealTime, effort)
if (pool.length === 0) return null
const others = avoidId ? pool.filter((recipe) => recipe.id !== avoidId) : pool
return pickRandom(others.length > 0 ? others : pool)
}

View File

@@ -0,0 +1,35 @@
import type { MealTime, MealType } from "@/types/domain"
export interface TimeContext {
mealTime: MealTime
mealType: MealType
hour: number
minute: number
}
/**
* Local-time meal context for the suggestion engine.
* until 10:30 → breakfast · until 14:30 → lunch · until 17:30 → snack · else dinner
*/
export function getTimeContext(date = new Date()): TimeContext {
const hour = date.getHours()
const minute = date.getMinutes()
const minutes = hour * 60 + minute
let mealTime: MealTime
if (minutes < 10 * 60 + 30) mealTime = "breakfast"
else if (minutes < 14 * 60 + 30) mealTime = "lunch"
else if (minutes < 17 * 60 + 30) mealTime = "snack"
else mealTime = "dinner"
const mealType: MealType =
mealTime === "breakfast" || mealTime === "snack" ? "half" : "full"
return { mealTime, mealType, hour, minute }
}
export function formatClock(hour: number, minute: number): string {
const hh = String(hour).padStart(2, "0")
const mm = String(minute).padStart(2, "0")
return `${hh}:${mm}`
}

View File

@@ -0,0 +1,118 @@
import { useMemo } from "react"
import { useLocation } from "react-router-dom"
import { IngredientChips } from "@/components/food/IngredientChips"
import { KitchenBackLink } from "@/components/home/NavCard"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, ui } from "@/i18n/ui"
import {
PLATE_SLOT_COLOR,
plateSlotForCategory,
resolveCatalogId,
type PlateSlot,
} from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import type { BuilderHandoffState, CatalogItem } from "@/types/domain"
const SLOT_ORDER: PlateSlot[] = ["protein", "produce", "fat", "berry"]
const SLOT_LABEL = {
protein: ui.pantry.legendProtein,
produce: ui.pantry.legendProduce,
fat: ui.pantry.legendFat,
berry: ui.pantry.legendBerry,
} as const
export function BuilderScreen() {
const { lang } = useLanguage()
const location = useLocation()
const handoff = location.state as BuilderHandoffState | null
const ingredientIds = useMemo(
() => handoff?.ingredientIds ?? [],
[handoff],
)
const items: CatalogItem[] = useMemo(
() =>
ingredientIds
.map((id) => resolveCatalogId(id))
.filter((item): item is CatalogItem => Boolean(item)),
[ingredientIds],
)
const bySlot = useMemo(() => {
const map: Record<PlateSlot, CatalogItem[]> = {
protein: [],
produce: [],
fat: [],
berry: [],
}
for (const item of items) {
if (!item.cat) continue
map[plateSlotForCategory(item.cat)].push(item)
}
return map
}, [items])
return (
<div className="flex flex-col gap-4">
<KitchenBackLink />
<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>
<div className="flex flex-col gap-3">
{SLOT_ORDER.map((slot) => {
const slotItems = bySlot[slot]
const filled = slotItems.length > 0
return (
<div
key={slot}
className={cx(
"rounded-[var(--radius-card)] p-3",
filled
? "ring-1 ring-[var(--border)]"
: "border border-dashed",
)}
style={{
background: filled ? "var(--glass-fill)" : "transparent",
borderColor: filled ? undefined : PLATE_SLOT_COLOR[slot],
}}
>
<p className="type-label mb-2 flex items-center gap-2 text-[var(--ink-faint)]">
<span
aria-hidden
className="h-2.5 w-2.5 rounded-full ring-1 ring-[var(--ink)]/20"
style={{ background: PLATE_SLOT_COLOR[slot] }}
/>
{t(SLOT_LABEL[slot], lang)}
</p>
{filled ? (
<IngredientChips items={slotItems} />
) : (
<p className="type-caption text-[var(--ink-faint)]">
{t(ui.stubs.builderMissing, lang)}
</p>
)}
</div>
)
})}
</div>
{items.length > 0 ? (
<p className="type-caption text-[var(--ink-faint)]">
{t(ui.stubs.builderHandoff, lang)}
</p>
) : null}
</div>
)
}

View File

@@ -0,0 +1,73 @@
import { KnowledgeBackLink } from "@/components/home/NavCard"
import { IngredientChips } from "@/components/food/IngredientChips"
import { NestedDisclosure } from "@/components/ui-pp/Accordion"
import { t, tr, ui } from "@/i18n/ui"
import { howtos, resolveRecipeUses } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import type { HowToRecipe } from "@/types/domain"
function HowToBody({ recipe, lang }: { recipe: HowToRecipe; lang: "de" | "en" }) {
const ingredients = resolveRecipeUses(recipe)
return (
<div className="flex flex-col gap-3">
{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.howto.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.howto.uses, lang)}
</p>
<IngredientChips items={ingredients} />
</div>
</div>
)
}
export function HowToLibraryScreen() {
const { lang } = useLanguage()
return (
<div className="flex flex-col gap-4">
<header>
<KnowledgeBackLink />
<h1 className="type-display text-[var(--ink)]">
{t(ui.howto.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.howto.intro, lang)}
</p>
</header>
<div className="flex flex-col gap-2">
{howtos.map((recipe) => (
<NestedDisclosure key={recipe.id} title={tr(recipe.name, lang)}>
<HowToBody recipe={recipe} lang={lang} />
</NestedDisclosure>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import { useNavigate } from "react-router-dom"
import { Blocks, Package, ShoppingBasket } from "lucide-react"
import { NavCard } from "@/components/home/NavCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
export function KitchenHubScreen() {
const { lang } = useLanguage()
const navigate = useNavigate()
const soon = t(ui.placeholders.badge, lang)
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.kitchen.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.kitchen.intro, lang)}
</p>
</header>
<div className="grid grid-cols-1 gap-3">
<NavCard
title={t(ui.kitchen.pantryTitle, lang)}
body={t(ui.kitchen.pantryBody, lang)}
gradient="var(--gradient-primary)"
icon={
<ShoppingBasket className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
onNavigate={() => navigate("/kitchen/pantry")}
/>
<NavCard
title={t(ui.kitchen.stockTitle, lang)}
body={t(ui.kitchen.stockBody, lang)}
gradient="var(--gradient-accent)"
badge={soon}
disabled
icon={<Package className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
/>
<NavCard
title={t(ui.kitchen.builderTitle, lang)}
body={t(ui.kitchen.builderBody, lang)}
gradient="var(--gradient-primary)"
badge={soon}
disabled
icon={<Blocks className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,55 @@
import { useNavigate } from "react-router-dom"
import { BookOpen, ChefHat, Settings2, Utensils } from "lucide-react"
import { NavCard } from "@/components/home/NavCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
export function KnowledgeHubScreen() {
const { lang } = useLanguage()
const navigate = useNavigate()
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.knowledge.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.knowledge.intro, lang)}
</p>
</header>
<div className="grid grid-cols-1 gap-3">
<NavCard
title={t(ui.knowledge.plateTitle, lang)}
body={t(ui.knowledge.plateBody, lang)}
gradient="var(--gradient-primary)"
icon={<Utensils className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/plate")}
/>
<NavCard
title={t(ui.knowledge.recipesTitle, lang)}
body={t(ui.knowledge.recipesBody, lang)}
gradient="var(--gradient-accent)"
icon={<ChefHat className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/recipes")}
/>
<NavCard
title={t(ui.knowledge.rulesTitle, lang)}
body={t(ui.knowledge.rulesBody, lang)}
gradient="var(--gradient-primary)"
icon={<BookOpen className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/rules")}
/>
<NavCard
title={t(ui.knowledge.settingsTitle, lang)}
body={t(ui.knowledge.settingsBody, lang)}
gradient="var(--gradient-accent)"
icon={<Settings2 className="h-5 w-5 text-[var(--ink)]" aria-hidden />}
onNavigate={() => navigate("/knowledge/settings")}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,156 @@
import { useMemo, useState } from "react"
import { CategorySoftChip } from "@/components/food/CategorySoftChip"
import { KitchenBackLink } from "@/components/home/NavCard"
import { Accordion } from "@/components/ui-pp/Accordion"
import { t, tr, ui } from "@/i18n/ui"
import { foodCategories, sortFoodItems } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import { signalGlyphChar, signalGlyphLabel } from "@/lib/signals"
import { DEFAULT_NUTRITION_BASIS } from "@/types/domain"
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()
/** Switch later when UI offers perServing / perUnit views. */
const displayBasis = DEFAULT_NUTRITION_BASIS
const sections = useMemo(
() =>
foodCategories
.map((category) => {
const items = sortFoodItems(
category.items.filter((item) =>
matchesQuery(item, normalized, lang),
),
lang,
displayBasis,
)
return { category, items }
})
.filter((section) => section.items.length > 0),
[lang, normalized, displayBasis],
)
return (
<div className="flex flex-col gap-4">
<header>
<KitchenBackLink />
<h1 className="type-display text-[var(--ink)]">
{t(ui.pantry.title, lang)}
</h1>
<p className="type-caption mt-3 text-[var(--ink-soft)]">
<span className="font-bold text-[var(--signal-success)]" aria-hidden>
</span>{" "}
{t(ui.pantry.pick, lang)}
{" · "}
<span className="font-bold text-[var(--signal-warning)]" aria-hidden>
!
</span>{" "}
{t(ui.pantry.caution, lang)}
</p>
{displayBasis === "per100g" ? (
<p className="type-caption mt-1 text-[var(--ink-faint)]">
{t(ui.pantry.per100gHint, lang)}
</p>
) : null}
</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(--ui-accent)]/30",
)}
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={<CategorySoftChip categoryId={category.id} />}
>
<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 min-w-0 truncate text-[var(--ink)]">
{tr(item.name, lang)}
</span>
<span
className={cx(
"type-caption inline-flex shrink-0 items-center gap-1.5 text-right text-[var(--ink-soft)]",
item.isApprox && "italic",
)}
>
<span>{tr(item.valueInfo, lang)}</span>
{item.tag ? (
<span
className={cx(
"font-bold",
item.tag === "pick"
? "text-[var(--signal-success)]"
: "text-[var(--signal-warning)]",
)}
aria-label={
item.tag === "caution" && item.cautionWhy
? `${signalGlyphLabel(item.tag, lang)}: ${tr(item.cautionWhy, lang)}`
: signalGlyphLabel(item.tag, lang)
}
title={
item.tag === "caution" && item.cautionWhy
? tr(item.cautionWhy, lang)
: undefined
}
>
{signalGlyphChar(item.tag)}
</span>
) : null}
</span>
</li>
))}
</ul>
{category.id !== "obst" ? (
<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>
) : null}
</Accordion>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,41 @@
import { HubBackLink } from "@/components/home/NavCard"
import { GlassCard } from "@/components/ui-pp/GlassCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
import type { LocalizedString } from "@/types/domain"
export function PlaceholderScreen({
backTo,
backLabel,
title,
body,
}: {
backTo: string
backLabel: LocalizedString
title: LocalizedString
body: LocalizedString
}) {
const { lang } = useLanguage()
return (
<div className="flex flex-col gap-4">
<header>
<HubBackLink to={backTo} label={backLabel} />
<p className="type-label text-[var(--ink-faint)]">
{t(ui.placeholders.badge, lang)}
</p>
<h1 className="type-display mt-1 text-[var(--ink)]">{t(title, lang)}</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">{t(body, lang)}</p>
</header>
<GlassCard className="p-5">
<p className="type-body text-[var(--ink-soft)]">
{t(ui.stubs.comingSoon, lang)}
</p>
<p className="type-caption mt-3 text-[var(--ink-faint)]">
Pocket Pascal · A+B Hub
</p>
</GlassCard>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { useNavigate } from "react-router-dom"
import { BookMarked, CalendarDays, CalendarRange, ShoppingCart } from "lucide-react"
import { NavCard } from "@/components/home/NavCard"
import { t, ui } from "@/i18n/ui"
import { useLanguage } from "@/lib/language"
export function PlanHubScreen() {
const { lang } = useLanguage()
const navigate = useNavigate()
const soon = t(ui.placeholders.badge, lang)
return (
<div className="flex flex-col gap-4">
<header>
<h1 className="type-display text-[var(--ink)]">
{t(ui.planHub.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.planHub.intro, lang)}
</p>
</header>
<div className="grid grid-cols-1 gap-3">
<NavCard
title={t(ui.planHub.weekTitle, lang)}
body={t(ui.planHub.weekBody, lang)}
gradient="var(--gradient-primary)"
icon={
<CalendarDays className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
onNavigate={() => navigate("/plan/week")}
/>
<NavCard
title={t(ui.planHub.mealPlannerTitle, lang)}
body={t(ui.planHub.mealPlannerBody, lang)}
gradient="var(--gradient-accent)"
badge={soon}
disabled
icon={
<CalendarRange className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
/>
<NavCard
title={t(ui.planHub.shoppingTitle, lang)}
body={t(ui.planHub.shoppingBody, lang)}
gradient="var(--gradient-primary)"
badge={soon}
disabled
icon={
<ShoppingCart className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
/>
<NavCard
title={t(ui.planHub.recipesTitle, lang)}
body={t(ui.planHub.recipesBody, lang)}
gradient="var(--gradient-accent)"
badge={soon}
disabled
icon={
<BookMarked className="h-5 w-5 text-[var(--ink)]" aria-hidden />
}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,264 @@
import { useEffect, useMemo, useRef, useState } from "react"
import { ChevronRight } from "lucide-react"
import { KnowledgeBackLink } from "@/components/home/NavCard"
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(--cat-protein)",
t: "var(--cat-produce)",
o: "var(--cat-fat)",
s: "var(--cat-berry)",
}
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="type-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{
background: color,
color:
step.cls === "o" ? "var(--cat-fat-fg)" : "var(--cat-on-dark)",
}}
>
{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 [selectedId, setSelectedId] = useState<number>(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(() => {
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
})
const stillVisible = visibleSteps.some((step) => step.n === selectedId)
if (!stillVisible && visibleSteps[0]) setSelectedId(visibleSteps[0].n)
}, [visibleSteps, selectedId])
function focusStep(n: number) {
setSelectedId(n)
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) {
setSelectedId(n)
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>
<KnowledgeBackLink />
<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-6 overflow-visible px-2 py-4">
<DiningPlate
key={mode}
size={240}
segments={segments}
selectedId={String(selectedId)}
aria-label={t(ui.plate.plateAria, lang)}
onSelect={(id) => focusStep(Number(id))}
/>
</div>
<div className="flex flex-col gap-3">
{visibleSteps.map((step) => (
<div key={step.n}>
<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,45 @@
import { useState } from "react"
import { KnowledgeBackLink } from "@/components/home/NavCard"
import { Accordion } from "@/components/ui-pp/Accordion"
import { t, tr, ui } from "@/i18n/ui"
import { foodCategories } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
export function RulesScreen() {
const { lang } = useLanguage()
const [openIds, setOpenIds] = useState<Record<string, boolean>>(() =>
Object.fromEntries(foodCategories.map((c) => [c.id, c.id === "protein"])),
)
return (
<div className="flex flex-col gap-4">
<header>
<KnowledgeBackLink />
<h1 className="type-display text-[var(--ink)]">
{t(ui.rules.title, lang)}
</h1>
<p className="type-body mt-2 text-[var(--ink-soft)]">
{t(ui.rules.intro, lang)}
</p>
</header>
<div className="flex flex-col gap-3">
{foodCategories.map((category) => (
<Accordion
key={category.id}
open={Boolean(openIds[category.id])}
onOpenChange={(open) =>
setOpenIds((current) => ({ ...current, [category.id]: open }))
}
title={tr(category.title, lang)}
>
<p className="type-body text-[var(--ink-soft)]">
{tr(category.thumb, lang)}
</p>
</Accordion>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { KnowledgeBackLink } from "@/components/home/NavCard"
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 (
<div className="flex flex-col gap-4">
<KnowledgeBackLink />
<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>
</div>
)
}

View File

@@ -0,0 +1,127 @@
import { useMemo, useState } from "react"
import { IngredientChips } from "@/components/food/IngredientChips"
import { PlanBackLink } from "@/components/home/NavCard"
import { Accordion } from "@/components/ui-pp/Accordion"
import { TimeIconBadge } from "@/components/ui-pp/TimeIconBadge"
import { t, tr, ui } from "@/i18n/ui"
import { MEAL_TIME_ORDER, recipesForMealTime, resolveRecipeUses } from "@/lib/catalog"
import { useLanguage } from "@/lib/language"
import { cx } from "@/lib/cx"
import type { Effort, MealTime, Recipe } from "@/types/domain"
function effortLabel(effort: Effort, lang: "de" | "en"): string {
if (effort === "none") return t(ui.weekplan.effortNone, lang)
if (effort === "quick") return t(ui.weekplan.effortQuick, lang)
return t(ui.weekplan.effortCook, lang)
}
/** Show when cooking, or when a concrete duration is 15+ minutes. */
function shouldShowEffortCaption(
effort: Effort,
durationMinutes?: number,
): boolean {
if (effort === "cook") return true
if (durationMinutes != null && durationMinutes >= 15) return true
return false
}
function EffortCaption({
effort,
lang,
}: {
effort: Effort
lang: "de" | "en"
}) {
return (
<p
className={cx(
"type-caption shrink-0 text-right",
effort === "none" && "text-[var(--ink-faint)]",
effort === "quick" && "text-[var(--ink-soft)]",
effort === "cook" && "font-bold text-[var(--signal-warning)]",
)}
>
{effortLabel(effort, lang)}
</p>
)
}
function RecipeRow({ recipe, lang }: { recipe: Recipe; lang: "de" | "en" }) {
const ingredients = resolveRecipeUses(recipe)
const showEffort = shouldShowEffortCaption(recipe.effort)
return (
<article className="border-b border-[var(--border)] py-3 last:border-0 last:pb-0 first:pt-0">
<div className="flex items-baseline justify-between gap-3">
<h3 className="type-title-sm min-w-0 text-[var(--ink)]">
{tr(recipe.name, lang)}
</h3>
{showEffort ? <EffortCaption effort={recipe.effort} lang={lang} /> : null}
</div>
<IngredientChips
className="mt-2.5"
items={ingredients}
maxVisible={4}
/>
</article>
)
}
export function WeekPlanScreen() {
const { lang } = useLanguage()
const [openTimes, setOpenTimes] = useState<Record<MealTime, boolean>>(() =>
Object.fromEntries(
MEAL_TIME_ORDER.map((time) => [time, time === "breakfast"]),
) as Record<MealTime, boolean>,
)
const groups = useMemo(
() =>
MEAL_TIME_ORDER.map((time) => ({
time,
items: recipesForMealTime(time),
})),
[],
)
return (
<div className="flex flex-col gap-4">
<header>
<PlanBackLink />
<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(({ time, items }) => (
<Accordion
key={time}
open={openTimes[time]}
onOpenChange={(open) =>
setOpenTimes((current) => ({ ...current, [time]: open }))
}
title={t(ui.weekplan[time], lang)}
meta={items.length}
icon={
<TimeIconBadge
mealTime={time}
aria-hidden
/>
}
>
<div className="flex flex-col">
{items.map((recipe) => (
<RecipeRow key={recipe.id} recipe={recipe} lang={lang} />
))}
</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);
}

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

@@ -0,0 +1,280 @@
/**
* Pocket Pascal — Elevated Surface Design Tokens
*
* Category palette (locked): Emerald Bridge + Neon Punch fat
* --cat-protein #2563EB Blau
* --cat-produce #059669 Emerald (Obst + Gemüse)
* --cat-fat #EAB308 Gold (from Neon Punch)
* --cat-berry #C0267A Berry (Kohlenhydrate)
*
* Signals (glyph-primary for pick/caution; color redundant):
* --signal-success / --signal-error / --signal-warning
*
* --ui-accent is non-semantic (chrome only — tabs, focus), not a plate category.
*/
:root {
/* ---------- Elevated surface (formerly "glass") ---------- */
--glass-fill: rgba(255, 255, 255, 0.9);
--glass-blur: 8px;
--glass-border-from: rgba(255, 255, 255, 0.9);
--glass-border-to: rgba(0, 0, 0, 0.06);
/* ---------- Radius scale ---------- */
--radius-card: 24px;
--radius-nested: 12px;
--radius-control: 16px;
--radius-pill: 9999px;
/* ---------- Shadows ---------- */
--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);
--shadow-ambient: var(--shadow-float);
/* ---------- Brand ink / neutrals ---------- */
--ink: #191800;
--ink-soft: #5c5b48;
--ink-faint: #6b6a56;
--border: #e4e2d8;
/* ---------- Canonical category palette ---------- */
--cat-protein: #2563eb;
--cat-produce: #059669;
--cat-fat: #eab308;
--cat-berry: #c0267a;
/* Text on category fills: gold needs dark ink; others use white */
--cat-fat-fg: #191800;
--cat-on-dark: #ffffff;
/* ---------- Signals (not categories) ---------- */
--signal-success: #0f7a37;
--signal-error: #c81e1e;
--signal-warning: #ea580c;
/* ---------- Non-semantic UI chrome ---------- */
--ui-accent: #191800;
--ui-accent-soft: #5c5b48;
/* ---------- Legacy aliases → canonical (safe during migration) ---------- */
--color-protein: var(--cat-protein);
--color-protein-dk: #1e3a8a;
--color-obstgem: var(--cat-produce);
--color-obstgem-dk: #065f46;
--color-fett: var(--cat-fat);
--color-fett-dk: #a16207;
--color-kh: var(--cat-berry);
--color-kh-dk: #9d174d;
--color-pick: var(--signal-success);
--color-caution: var(--signal-error);
--plate-protein: var(--cat-protein);
--plate-veg: var(--cat-produce);
--plate-fat: var(--cat-fat);
--plate-carb: var(--cat-berry);
--plate-protein-soft: #dbeafe;
--plate-veg-soft: #d1fae5;
--plate-fat-soft: #fef9c3;
--plate-carb-soft: #fce7f3;
--green: var(--cat-produce);
--green-soft: var(--plate-veg-soft);
--terracotta: var(--cat-berry);
--terracotta-soft: var(--plate-carb-soft);
--gold: var(--cat-fat);
--gold-soft: var(--plate-fat-soft);
--slate: var(--cat-protein);
--slate-soft: var(--plate-protein-soft);
/* ---------- Surfaces ---------- */
--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%);
/* Decorative UI gradients — lime-vibrant brand chrome (NOT category encoding).
Paired with --ink icons/text for WCAG AA on these light stops. */
--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%);
--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;
--cat-protein: #2563eb;
--cat-produce: #059669;
--cat-fat: #eab308;
--cat-berry: #c0267a;
--cat-fat-fg: #191800;
--cat-on-dark: #ffffff;
--signal-success: #4ade80;
--signal-error: #ff6b60;
--signal-warning: #fb923c;
--ui-accent: #f3f2e6;
--ui-accent-soft: #c2c1ac;
--color-pick: var(--signal-success);
--color-caution: var(--signal-error);
--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 ---------- */
@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-display {
font-family: "Unbounded", ui-sans-serif, sans-serif;
font-weight: 900;
font-size: 1.5rem;
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;
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;
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;
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;
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;
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;
font-size: 0.875rem;
line-height: 1.2;
}
.glass-border {
border-radius: inherit;
padding: 1px;
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;
}
.elevation-transition {
transition: box-shadow 120ms ease-out, transform 120ms ease-out, filter 120ms ease-out;
}
html,
body {
-webkit-tap-highlight-color: transparent;
overscroll-behavior-y: contain;
}
.no-select {
-webkit-user-select: none;
user-select: none;
}

104
client/src/types/domain.ts Normal file
View File

@@ -0,0 +1,104 @@
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"
/** Plate size per building-block rule. */
export type MealType = "full" | "half"
/** Day-part for plate suggestions (RECIPES.times). */
export type MealTime = "breakfast" | "lunch" | "snack" | "dinner"
export type Effort = "none" | "quick" | "cook"
/** How kcal / protein on a food record are measured. */
export type NutritionBasis = "per100g" | "perServing" | "perUnit"
/**
* Default display / sort basis for pantry lists.
* Switch later when the UI offers perServing / perUnit views.
*/
export const DEFAULT_NUTRITION_BASIS: NutritionBasis = "per100g"
export interface FoodItem {
id: string
cat: CategoryId
name: LocalizedString
/** Display string (macros or qualitative e.g. vitamins). */
valueInfo: LocalizedString
/** Measurement basis for kcal / proteinG. */
basis: NutritionBasis
kcal?: number
proteinG?: number
tag?: TagType
/** Required when tag is caution — why enjoy mindfully. */
cautionWhy?: LocalizedString
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
cautionWhy?: LocalizedString
}
/**
* Curated plate suggestion (half / full meal).
* `uses` references ids from CATS (foods) or EXTRAS.
*/
export interface Recipe {
id: string
type: MealType
times: MealTime[]
effort: Effort
name: LocalizedString
uses: string[]
}
/**
* Prep / how-to instruction — not a plate suggestion.
* Kept separate from RECIPES.
*/
export interface HowToRecipe {
id: string
name: LocalizedString
uses: string[]
text?: LocalizedString
variations?: LocalizedString[]
}
export interface BuilderHandoffState {
ingredientIds: string[]
source?: "suggestion" | "home"
}

View File

@@ -13,6 +13,7 @@
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx",

View File

@@ -15,9 +15,9 @@ export default defineConfig({
manifest: {
name: 'Pocket Pascal',
short_name: 'Pocket Pascal',
description: 'A simple Hello World PWA with a React frontend and SQLite backend.',
theme_color: '#0a0a0a',
background_color: '#0a0a0a',
description: 'Nutrition decision aid based on Pascals building-block plate system.',
theme_color: '#fafaf7',
background_color: '#fafaf7',
display: 'standalone',
start_url: '/',
icons: [

43
handover/CLAUDE.md Normal file
View File

@@ -0,0 +1,43 @@
# Pocket Pascal
Nutrition coach app for Steffi + David, built on Pascal Wirth's Ernährungsbaukasten (building-block nutrition system). Decision aid for "what do I eat right now" — not a tracker, not chat-first. See `/docs/CONCEPT.md` for the full spec.
## Before touching this repo
Read `/docs/CONCEPT.md` in full. It is the source of truth for:
- Vision, non-goals, and the "app should make itself obsolete" success criterion
- The plate/building-block rules (protein, fat, carbs, veg/fruit) and portion sizes
- The module list and which phase each belongs to
- The decision log (section 8) — settled calls, do not re-litigate without a documented reason
- Open questions (section 7) — several are marked as UX calls Steffi owns; don't decide those unilaterally
## Platform
Web/PWA. **Not** a native iOS app. Decided 2026-08-03 (see decision log) — only 2 users, no App Store needed, and the existing vanilla JS data model/logic carries over directly.
Target stack: React + shadcn/ui + Tailwind, hosted on Vercel or GitHub Pages (exact host still an open question — see concept doc §7).
## Data model
`/data/foods.json` and `/data/steps.json` are extracted from the working prototype. Structured records, not display strings:
```json
{ "id": "chicken_breast", "cat": "protein",
"name": { "de": "Hühnerbrust", "en": "Chicken breast" },
"v": { "de": "111 kcal · 24 g", "en": "111 kcal · 24 g" },
"f": "pick" }
```
`f` is `"pick"` (especially recommended), `"caution"` (enjoy mindfully), or absent. `v` is currently a display string per item — **not yet normalized to a common basis**. Pantry items are mostly per-100g; snack items are per-serving. Normalizing everything to per-100g is a prerequisite before quantities/calorie math can be added (see concept doc, Teller-Builder — Analyse-Layer module).
DE is the primary working language (Steffi edits it directly), EN is the general template — every user-facing string needs both. UI chrome strings (not food data) live in a separate dictionary, not duplicated HTML.
## What's already built (reference only — not authoritative, rebuild in the new stack)
`/reference/kueche_app.html` — working single-file prototype (Claude Artifact). Has: plate rule view, pantry/knowledge base with search, Teller-Builder (multi-select plate builder with pick/caution scoring), settings with language switch. Useful for behavior reference, not for copy-pasting into the new stack as-is.
## Working conventions
- Bilingual: every content change ships both `de` and `en` in the same commit.
- Curated, not exhaustive — see non-goals in the concept doc. Don't add a food-database API integration without checking §7 first.
- UX/UI decisions are Steffi's call (she's the UX designer on this project). Flag them, don't just decide.

665
handover/CONCEPT.html Normal file
View File

@@ -0,0 +1,665 @@
<title>Pocket Pascal — Konzeptdokument</title>
<style>
:root{
--bg:#fbfbfa; --surface:#ffffff; --ink:#14150f; --ink-soft:#5f6055; --ink-faint:#8b8c80;
--line:#e4e4de; --line-soft:#efefe9;
--green:#2F5233; --green-soft:#e3ece4;
--sage:#4A6FA5; --sage-soft:#e7edf5;
--coral:#B33D22; --coral-soft:#f9e7e1;
--leaf:#6B7A22; --leaf-soft:#eff2df;
--gold:#8A6A1A; --gold-soft:#fdf1d9;
--plum:#6B4C9A; --plum-soft:#efe9f5;
}
@media (prefers-color-scheme: dark){
:root{
--bg:#16170f; --surface:#1e1f18; --ink:#f2f1e6; --ink-soft:#b6b6a8; --ink-faint:#84857a;
--line:#33342b; --line-soft:#27281f;
--green:#8FB583; --green-soft:#242e23;
--sage:#93B3D8; --sage-soft:#1f2836;
--coral:#E58B67; --coral-soft:#331f18;
--leaf:#B5C963; --leaf-soft:#262b17;
--gold:#D7B565; --gold-soft:#302713;
--plum:#B197DA; --plum-soft:#26203a;
}
}
:root[data-theme="dark"]{
--bg:#16170f; --surface:#1e1f18; --ink:#f2f1e6; --ink-soft:#b6b6a8; --ink-faint:#84857a;
--line:#33342b; --line-soft:#27281f;
--green:#8FB583; --green-soft:#242e23; --sage:#93B3D8; --sage-soft:#1f2836;
--coral:#E58B67; --coral-soft:#331f18; --leaf:#B5C963; --leaf-soft:#262b17;
--gold:#D7B565; --gold-soft:#302713; --plum:#B197DA; --plum-soft:#26203a;
}
:root[data-theme="light"]{
--bg:#fbfbfa; --surface:#ffffff; --ink:#14150f; --ink-soft:#5f6055; --ink-faint:#8b8c80;
--line:#e4e4de; --line-soft:#efefe9;
--green:#2F5233; --green-soft:#e3ece4; --sage:#4A6FA5; --sage-soft:#e7edf5;
--coral:#B33D22; --coral-soft:#f9e7e1; --leaf:#6B7A22; --leaf-soft:#eff2df;
--gold:#8A6A1A; --gold-soft:#fdf1d9; --plum:#6B4C9A; --plum-soft:#efe9f5;
}
*{ box-sizing:border-box; }
body{
background:var(--bg); color:var(--ink); margin:0;
font-family: ui-sans-serif, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-size:16px; line-height:1.6;
padding:40px 20px 80px;
}
body:not([data-lang="en"]) .en{ display:none; }
body[data-lang="en"] .de{ display:none; }
.display{ font-family:"Futura","Century Gothic","Avenir Next","Avenir",ui-sans-serif,sans-serif; font-weight:800; letter-spacing:0.01em; text-wrap:balance; }
.wrap{ max-width:760px; margin:0 auto; display:flex; flex-direction:column; gap:0; }
.langbar{
position:sticky; top:12px; z-index:20; align-self:flex-end;
display:flex; gap:2px; background:var(--surface); border:1px solid var(--line);
border-radius:999px; padding:3px; box-shadow:0 2px 10px rgba(0,0,0,0.07);
}
.langbar button{
border:none; background:transparent; padding:6px 15px; border-radius:999px; cursor:pointer;
font-size:0.74rem; font-weight:800; letter-spacing:0.08em; color:var(--ink-soft);
font-family:inherit;
}
.langbar button:focus-visible{ outline:2px solid var(--sage); outline-offset:2px; }
body:not([data-lang="en"]) .langbar button.de,
body[data-lang="en"] .langbar button.en{ background:var(--green); color:var(--bg); }
header.doc{ display:flex; flex-direction:column; gap:10px; padding-bottom:26px; border-bottom:2px solid var(--ink); margin-bottom:34px; }
.kicker{ text-transform:uppercase; letter-spacing:0.16em; font-size:0.68rem; font-weight:800; color:var(--coral); }
header.doc h1{ font-size:clamp(2rem,5vw,2.7rem); margin:0; text-transform:uppercase; line-height:1.05; }
.meta{ display:flex; flex-wrap:wrap; gap:8px 18px; font-size:0.74rem; color:var(--ink-soft); margin-top:4px; }
.meta b{ color:var(--ink); font-weight:700; }
section{ margin-bottom:38px; }
h2{ font-family:"Futura","Century Gothic","Avenir Next",ui-sans-serif,sans-serif; font-size:1.05rem; text-transform:uppercase; letter-spacing:0.07em; font-weight:800; margin:0 0 4px; display:flex; align-items:baseline; gap:10px; }
h2 .idx{ font-size:0.72rem; color:var(--ink-faint); font-weight:700; letter-spacing:0.05em; }
.lede{ color:var(--ink-soft); font-size:0.92rem; margin:0 0 18px; padding-bottom:14px; border-bottom:1px solid var(--line); }
h3{ font-size:0.88rem; font-weight:800; margin:22px 0 6px; letter-spacing:0.01em; }
p{ margin:0 0 12px; }
ul{ margin:0 0 12px; padding-left:20px; }
li{ margin-bottom:5px; }
li::marker{ color:var(--ink-faint); }
strong{ font-weight:700; }
.callout{ background:var(--surface); border:1px solid var(--line); border-left:3px solid var(--coral); border-radius:0 8px 8px 0; padding:14px 16px; margin:0 0 16px; font-size:0.9rem; }
.callout.ok{ border-left-color:var(--green); }
.callout.info{ border-left-color:var(--sage); }
.callout b{ display:block; font-size:0.7rem; text-transform:uppercase; letter-spacing:0.08em; margin-bottom:4px; color:var(--coral); }
.callout.ok b{ color:var(--green); }
.callout.info b{ color:var(--sage); }
pre{ background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:14px 16px; overflow-x:auto; margin:0 0 16px; }
code{ font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:0.8rem; line-height:1.55; }
p code, li code, td code{ background:var(--line-soft); padding:1px 5px; border-radius:4px; font-size:0.82em; }
.modules{ display:flex; flex-direction:column; gap:10px; }
.mod{ background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:14px 16px; display:flex; flex-direction:column; gap:5px; }
.mod-top{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.mod-name{ font-weight:800; font-size:0.92rem; flex:1; min-width:160px; }
.mod p{ margin:0; font-size:0.85rem; color:var(--ink-soft); }
.badge{ font-size:0.62rem; font-weight:800; text-transform:uppercase; letter-spacing:0.08em; padding:3px 9px; border-radius:999px; flex:none; }
.badge.p1{ background:var(--green-soft); color:var(--green); }
.badge.p2{ background:var(--gold-soft); color:var(--gold); }
.badge.p3{ background:var(--plum-soft); color:var(--plum); }
.badge.done{ background:var(--sage-soft); color:var(--sage); }
.tablewrap{ overflow-x:auto; border:1px solid var(--line); border-radius:8px; margin-bottom:16px; }
table{ width:100%; border-collapse:collapse; font-size:0.84rem; font-variant-numeric:tabular-nums; }
th{ text-align:left; background:var(--surface); font-size:0.66rem; text-transform:uppercase; letter-spacing:0.07em; color:var(--ink-soft); padding:9px 12px; border-bottom:1px solid var(--line); white-space:nowrap; }
td{ padding:9px 12px; border-bottom:1px solid var(--line-soft); vertical-align:top; }
tbody tr:last-child td{ border-bottom:none; }
.qlist{ counter-reset:q; list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:9px; }
.qlist li{ counter-increment:q; background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:12px 14px 12px 44px; position:relative; font-size:0.88rem; margin:0; }
.qlist li::before{ content:"Q" counter(q); position:absolute; left:13px; top:12px; font-size:0.66rem; font-weight:800; color:var(--coral); letter-spacing:0.04em; }
.qlist .who{ display:block; font-size:0.7rem; color:var(--ink-faint); margin-top:3px; }
footer.doc{ border-top:1px solid var(--line); padding-top:18px; font-size:0.74rem; color:var(--ink-faint); }
.loop{ margin:16px 0 14px; }
.loop-row{ display:flex; align-items:stretch; flex-wrap:wrap; gap:0; }
.loop-node{
flex:1 1 130px; background:var(--surface); border:1px solid var(--line); border-radius:10px;
padding:12px 13px; font-size:0.8rem; font-weight:700; text-align:center; line-height:1.3;
display:flex; align-items:center; justify-content:center; min-height:56px;
}
.loop-node .sub{ display:block; font-weight:400; font-size:0.72rem; color:var(--ink-soft); margin-top:3px; }
.loop-arrow{ flex:0 0 auto; display:flex; align-items:center; justify-content:center; padding:0 8px; font-size:1.15rem; color:var(--ink-faint); }
@media (max-width:620px){ .loop-arrow{ width:100%; padding:4px 0; transform:rotate(90deg); } }
.loop-back{
display:flex; align-items:center; gap:10px; margin-top:2px; padding:10px 14px;
border:1px dashed var(--coral); border-radius:10px; background:var(--coral-soft);
font-size:0.8rem; color:var(--ink);
}
.loop-back .icon{ font-size:1.2rem; color:var(--coral); flex:none; }
.loop-base{
display:flex; align-items:center; gap:10px; margin-top:10px; padding:10px 14px;
border:1px solid var(--line); border-radius:10px; background:var(--sage-soft);
font-size:0.8rem; color:var(--ink);
}
.loop-base .icon{ font-size:1.2rem; color:var(--sage); flex:none; }
</style>
<div class="wrap">
<div class="langbar">
<button class="de" onclick="document.body.setAttribute('data-lang','de')">DE</button>
<button class="en" onclick="document.body.setAttribute('data-lang','en')">EN</button>
</div>
<header class="doc">
<span class="kicker"><span class="de">Arbeitsdokument · intern</span><span class="en">Working document · internal</span></span>
<h1 class="display">Pocket Pascal</h1>
<p style="margin:0; color:var(--ink-soft); font-size:1rem;">
<span class="de">Ein Ernährungs-Coach für die Hosentasche. Verhindert schlechte Entscheidungen im Moment — statt Gewohnheiten zu überwachen.</span>
<span class="en">A nutrition coach for your pocket. Prevents poor decisions in the moment — instead of monitoring habits.</span>
</p>
<div class="meta">
<span><b><span class="de">Stand</span><span class="en">Version</span></b> 2026-08-03</span>
<span><b><span class="de">Autoren</span><span class="en">Authors</span></b> Steffi + Claude</span>
<span><b>Status</b> <span class="de">Entwurf, Phase 1 offen</span><span class="en">Draft, Phase 1 open</span></span>
<span><b><span class="de">Sprachen</span><span class="en">Languages</span></b> DE (primär) / EN</span>
</div>
</header>
<!-- 1 VISION -->
<section>
<h2><span class="idx">01</span><span class="de">Vision</span><span class="en">Vision</span></h2>
<p class="lede">
<span class="de">Was die App ist — und vor allem, was sie nicht ist.</span>
<span class="en">What the app is — and more importantly, what it is not.</span>
</p>
<p>
<span class="de">Pocket Pascal ist eine Entscheidungshilfe für den Moment: „Es ist 16 Uhr, ich habe Hunger, was jetzt?" Die App beantwortet das in wenigen Taps, gestützt auf Pascals Ernährungsbaukasten und den vorhandenen Vorrat.</span>
<span class="en">Pocket Pascal is a decision aid for the moment: "It's 4pm, I'm hungry, what now?" The app answers that in a few taps, based on Pascal's building-block system and what's actually in the kitchen.</span>
</p>
<p>
<span class="de">Das Erfolgskriterium ist ungewöhnlich: Die App soll sich <strong>überflüssig machen</strong>. Wenn die Daumenregeln verinnerlicht sind, wird sie seltener gebraucht. Das ist gewollt, kein Fehler.</span>
<span class="en">The success criterion is unusual: the app should <strong>make itself obsolete</strong>. Once the rules of thumb are internalised, it gets used less. That's intended, not a flaw.</span>
</p>
<h3><span class="de">Nicht-Ziele</span><span class="en">Non-goals</span></h3>
<ul>
<li><span class="de"><strong>Kein Tracker.</strong> Kein Kalorienzählen, keine Streaks, keine Schuldgefühl-Mechanik. Protokolliert wird im Coaching-Tool (Nutrilize), nicht hier.</span><span class="en"><strong>Not a tracker.</strong> No calorie counting, no streaks, no guilt mechanics. Logging happens in the coaching tool (Nutrilize), not here.</span></li>
<li><span class="de"><strong>Kein Chat-first-Interface.</strong> Buttons und klare UI schlagen offene Konversation. Ausnahme: tiefe Manager-Module (Kühlschrank, Wochenplanung) in Phase 3.</span><span class="en"><strong>Not chat-first.</strong> Buttons and clear UI beat open-ended conversation. Exception: deeper manager modules (fridge, weekly planning) in Phase 3.</span></li>
<li><span class="de"><strong>Kein Ersatz für den Coach.</strong> Die App ist Nachschlagewerk und Anwendungshilfe für Pascals Vorgaben, keine eigene Ernährungsberatung.</span><span class="en"><strong>Not a replacement for the coach.</strong> The app applies Pascal's guidance; it does not give its own nutritional advice.</span></li>
<li><span class="de"><strong>Keine Vollständigkeit.</strong> Lieber 40 gute Rezepte als 4.000 mittelmäßige. Der Datensatz bleibt kuratiert.</span><span class="en"><strong>Not exhaustive.</strong> Better 40 good recipes than 4,000 mediocre ones. The dataset stays curated.</span></li>
</ul>
<div class="callout">
<b><span class="de">Kernspannung</span><span class="en">Core tension</span></b>
<span class="de">Flexibilität gegen Struktur. Zu starr → wird umgangen. Zu offen → gibt keine Antwort und wird nicht geöffnet. Jede Funktion muss sich an dieser Achse messen lassen.</span>
<span class="en">Flexibility versus structure. Too rigid → gets circumvented. Too open → gives no answer and doesn't get opened. Every feature has to be measured against this axis.</span>
</div>
</section>
<!-- 2 NUTZER -->
<section>
<h2><span class="idx">02</span><span class="de">Nutzer &amp; Bedürfnisse</span><span class="en">Users &amp; needs</span></h2>
<p class="lede">
<span class="de">Zwei Personen, zwei sehr unterschiedliche Einstiegspunkte in dieselbe Logik.</span>
<span class="en">Two people, two very different entry points into the same logic.</span>
</p>
<div class="tablewrap">
<table>
<thead>
<tr>
<th><span class="de">Nutzer</span><span class="en">User</span></th>
<th><span class="de">Hauptbedürfnis</span><span class="en">Primary need</span></th>
<th><span class="de">Bedient durch</span><span class="en">Served by</span></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Steffi</strong></td>
<td><span class="de">Repertoire erweitern. Weiß bei manchen Zutaten nicht, was sie damit anfangen soll (z.&nbsp;B. Proteinpulver). Will Neues ausprobieren und eine mentale Rezeptbibliothek aufbauen.</span><span class="en">Broaden her repertoire. Doesn't know what to do with certain ingredients (e.g. protein powder). Wants to try new things and build a mental recipe library.</span></td>
<td><span class="de">Rezeptbibliothek, Vorschlagsmaschine</span><span class="en">Recipe library, suggestion engine</span></td>
</tr>
<tr>
<td><strong>David</strong></td>
<td><span class="de">Einkaufen mit richtiger Kategorie-Balance. Braucht (a) ein Gefühl für Lebensmittelkategorien im Laden und (b) Ideen für das, was schon zu Hause ist.</span><span class="en">Shopping with the right category balance. Needs (a) an intuitive sense for food categories while shopping and (b) ideas for what's already at home.</span></td>
<td><span class="de">Einkaufshilfe, Knowledge Base, Kühlschrank</span><span class="en">Shopping aid, knowledge base, fridge</span></td>
</tr>
</tbody>
</table>
</div>
<p>
<span class="de">Beide Bedürfnisse laufen auf dieselbe Datenschicht hinaus: Lebensmittel mit Kategorie, Rezepte als Kombinationen daraus. Der Unterschied liegt nur in der Richtung der Abfrage — „was koche ich aus X?" gegen „was muss ich für die Woche kaufen?".</span>
<span class="en">Both needs resolve to the same data layer: foods with a category, recipes as combinations of them. The only difference is the direction of the query — "what do I cook from X?" versus "what do I need to buy for the week?".</span>
</p>
</section>
<!-- 3 PLATE LOGIC -->
<section>
<h2><span class="idx">03</span><span class="de">Plate Logic — das Regelwerk</span><span class="en">Plate logic — the rule set</span></h2>
<p class="lede">
<span class="de">Der inhaltliche Kern. Stammt aus Pascals Ernährungsbaukasten und Einkaufsguide.</span>
<span class="en">The substantive core. Sourced from Pascal's building-block system and shopping guide.</span>
</p>
<h3><span class="de">Baukasten-Reihenfolge</span><span class="en">Building-block sequence</span></h3>
<ul>
<li><span class="de"><strong>1. Protein</strong> — 30 g pro Mahlzeit, 150 g am Tag</span><span class="en"><strong>1. Protein</strong> — 30 g per meal, 150 g per day</span></li>
<li><span class="de"><strong>2. Obst oder Gemüse</strong> — eine Portion (Obst 100 g, Gemüse 200 g)</span><span class="en"><strong>2. Fruit or vegetables</strong> — one portion (fruit 100 g, veg 200 g)</span></li>
<li><span class="de"><strong>3. Fett</strong> — 15 g Kerne/Nüsse, ¼ Avocado oder 1 EL Öl</span><span class="en"><strong>3. Fat</strong> — 15 g seeds/nuts, ¼ avocado or 1 tbsp oil</span></li>
<li><span class="de"><strong>4. Kohlenhydrate</strong> — 75 g trocken, 250 g Kartoffeln oder 50 g Haferflocken</span><span class="en"><strong>4. Carbohydrates</strong> — 75 g dry, 250 g potatoes or 50 g oats</span></li>
</ul>
<h3><span class="de">Tellerverteilung</span><span class="en">Plate distribution</span></h3>
<ul>
<li><span class="de"><strong>Ganze Mahlzeit</strong> — ⅓ Protein, ⅓ Gemüse/Obst, ⅓ Fett + Kohlenhydrate</span><span class="en"><strong>Full meal</strong> — ⅓ protein, ⅓ veg/fruit, ⅓ fat + carbs</span></li>
<li><span class="de"><strong>Halbe Mahlzeit (Snack)</strong> — Protein + Gemüse/Obst, ohne Kohlenhydrate</span><span class="en"><strong>Half meal (snack)</strong> — protein + veg/fruit, no carbs</span></li>
</ul>
<h3><span class="de">Tagesstruktur</span><span class="en">Day structure</span></h3>
<p>
<span class="de">4 Mahlzeiten, ca. alle 34 Std. Morgens Protein + Obst · mittags ganzer Teller · nachmittags Proteinsnack + Obst/Gemüse · abends ganzer Teller. 2 L Wasser täglich.</span>
<span class="en">4 meals, roughly every 34 hours. Morning protein + fruit · midday full plate · afternoon protein snack + fruit/veg · evening full plate. 2 L water daily.</span>
</p>
<p>
<span class="de">Diese Struktur ist zugleich die <strong>Vorschlagslogik</strong>: Aus der Uhrzeit folgt der Mahlzeitentyp, aus dem Typ folgt die Zusammensetzung. Kein Rätselraten nötig.</span>
<span class="en">This structure doubles as the <strong>suggestion logic</strong>: time of day determines the meal type, meal type determines the composition. No guesswork needed.</span>
</p>
<h3><span class="de">Einkaufsregel</span><span class="en">Shopping rule</span></h3>
<p>
<span class="de">Pro Einkauf mindestens 5 Proteinquellen, 5 Obst-/Gemüsesorten, 4 Fettquellen, 4 Kohlenhydratquellen. Bedarf = Tage bis zum nächsten Einkauf × Mahlzeiten pro Tag. Diese Formel ist direkt rechenbar und wird zur Einkaufshilfe.</span>
<span class="en">Per shopping trip: at least 5 protein sources, 5 fruit/veg varieties, 4 fat sources, 4 carb sources. Demand = days until next trip × meals per day. This formula is directly computable and becomes the shopping aid.</span>
</p>
<h3><span class="de">Daumenregeln (Etikett-Check)</span><span class="en">Rules of thumb (label check)</span></h3>
<ul>
<li><span class="de"><strong>Protein</strong> — mindestens 1520 g pro 100 g, wenig Zucker &amp; Fett, kurze Zutatenliste</span><span class="en"><strong>Protein</strong> — at least 1520 g per 100 g, low sugar &amp; fat, short ingredient list</span></li>
<li><span class="de"><strong>Kohlenhydrate</strong> — Vollkorn als erste Zutat; Faustregel 10:1 (pro 10 g KH mind. 1 g Ballaststoffe)</span><span class="en"><strong>Carbs</strong> — whole grain as first ingredient; 10:1 rule (at least 1 g fibre per 10 g carbs)</span></li>
<li><span class="de"><strong>Fett</strong> — native/kaltgepresste Öle, ungesalzene Nüsse, Zutatenliste = nur die Zutat</span><span class="en"><strong>Fat</strong> — native/cold-pressed oils, unsalted nuts, ingredient list = just the ingredient</span></li>
<li><span class="de"><strong>Zucker</strong> — Tagesbudget = kcal × 10 % ÷ 4,1. Versteckt hinter „-ose", „…zucker", „…sirup"</span><span class="en"><strong>Sugar</strong> — daily budget = kcal × 10% ÷ 4.1. Hidden behind "-ose", "…sugar", "…syrup"</span></li>
</ul>
</section>
<!-- 4 MODULE -->
<section>
<h2><span class="idx">04</span><span class="de">Module</span><span class="en">Modules</span></h2>
<p class="lede">
<span class="de">Sieben Bausteine, nach Phase sortiert. Jeder für sich nutzbar.</span>
<span class="en">Seven building blocks, sorted by phase. Each usable on its own.</span>
</p>
<div class="modules">
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Knowledge Base</span><span class="en">Knowledge base</span></span>
<span class="badge done"><span class="de">Steht</span><span class="en">Built</span></span>
<span class="badge p1">Phase 1</span>
</div>
<p><span class="de">Teller-Regel und Vorratskammer als Nachschlagewerk: Kategorien aufklappbar, Suche, Punkte-Markierung (empfehlenswert / bewusst genießen), Daumenregeln pro Kategorie. Das Fundament, auf das alle anderen Module zugreifen. Muss auf das strukturierte Datenmodell umgestellt werden.</span><span class="en">Plate rule and pantry as a reference: collapsible categories, search, dot markers (recommended / enjoy mindfully), rules of thumb per category. The foundation all other modules read from. Needs migrating to the structured data model.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Rezeptbibliothek</span><span class="en">Recipe library</span></span>
<span class="badge p1">Phase 1</span>
</div>
<p><span class="de">Kuratierte Sammlung von Mahlzeiten als Kombinationen aus Baukasten-Zutaten. Jedes Rezept kennt seine Zutaten-IDs, den Mahlzeitentyp und die Tageszeit. Startbestand: die 13 Beispielmahlzeiten aus der Ernährungscheckliste, danach schrittweise erweitert. Ohne diese Schicht kann die Vorschlagsmaschine nur die Tellerverteilung wiederholen.</span><span class="en">A curated collection of meals as combinations of building-block ingredients. Each recipe knows its ingredient IDs, meal type and time of day. Seed: the 13 example meals from the nutrition checklist, expanded gradually. Without this layer the suggestion engine can only repeat the plate ratio.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Vorschlagsmaschine</span><span class="en">Suggestion engine</span></span>
<span class="badge p1">Phase 1</span>
</div>
<p><span class="de">„Was esse ich jetzt?" Ein Tap. Uhrzeit bestimmt den Vorschlag (halbe oder ganze Mahlzeit), manuelle Umschaltung bleibt möglich. Zieht passende Rezepte aus der Bibliothek. Rein regelbasiert, keine KI nötig. Später filterbar nach Kühlschrankinhalt.</span><span class="en">"What do I eat now?" One tap. Time of day drives the suggestion (half or full meal), with manual override. Pulls matching recipes from the library. Purely rule-based, no AI needed. Later filterable by fridge contents.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Teller-Builder</span><span class="en">Plate builder</span></span>
<span class="badge done"><span class="de">Steht</span><span class="en">Built</span></span>
<span class="badge p1">Phase 1</span>
</div>
<p><span class="de">Pro Kategorie (Protein, Fett, Kohlenhydrat, Obst/Gemüse) beliebig viele Lebensmittel als Chips antippen — die App bewertet die Kombination gegen die Baukasten-Regeln und die Pick/Caution-Markierungen: guter Teller, guter Snack, oder was fehlt. Kein Backend nötig, reine Client-Logik auf dem bestehenden Datenmodell. Übersetzt die Vorratskammer direkt in gefühltes Wissen — trifft die Vision „App macht sich überflüssig" am direktesten.</span><span class="en">Tap any number of foods per category (protein, fat, carb, veg/fruit) as chips — the app evaluates the combination against the building-block rules and the pick/caution tags: good plate, good snack, or what's missing. No backend needed, pure client-side logic on the existing data model. Translates the pantry straight into felt intuition — hits the "app makes itself obsolete" vision most directly.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Teller-Builder — Analyse-Layer</span><span class="en">Plate builder — analysis layer</span></span>
<span class="badge p2">Phase 2</span>
</div>
<p><span class="de">Baut auf dem Teller-Builder auf, sobald Mengen dazukommen. Vier Parameter: <strong>Kalorien</strong> (Summe aus Mengen × Nährwert), <strong>Makros</strong> (Protein/Fett/KH gegen Baukasten-Zielwerte), <strong>Vielfalt</strong> (abgedeckte Vitamine/Mineralien je Teller, später auch über die Woche), <strong>Zucker</strong> (Tagesbudget-Regel aus dem Zucker-Guide). Mengen bekommen einen Vorschlagswert aus den Baukasten-Standardmengen (30 g Protein, 15 g Fett, 75 g KH, 100 g Obst/200 g Gemüse), editierbar. Ausgewählte Kombination als Favorit speichern — landet direkt in der Rezeptbibliothek, keine zweite Datenstruktur nötig. Voraussetzung: alle Lebensmittel auf eine einheitliche Basis (pro 100 g) normalisieren — aktuell sind Snacks pro Portion, der Rest pro 100 g angegeben.</span><span class="en">Builds on the plate builder once quantities are added. Four parameters: <strong>calories</strong> (sum of quantity × nutrition value), <strong>macros</strong> (protein/fat/carbs against building-block targets), <strong>variety</strong> (vitamins/minerals covered per plate, later across the week too), <strong>sugar</strong> (daily-budget rule from the sugar guide). Quantities get a suggested value from the building-block defaults (30 g protein, 15 g fat, 75 g carbs, 100 g fruit/200 g veg), editable. Save a combination as a favourite — it lands straight in the recipe library, no second data structure needed. Prerequisite: normalise all foods onto one basis (per 100 g) — currently snacks are per serving, everything else per 100 g.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name">Settings</span>
<span class="badge p1">Phase 1</span>
</div>
<p><span class="de">Sprachumschaltung DE/EN, gespeichert. Später: Mahlzeiten pro Tag, Einkaufsrhythmus, Kalorienrichtwert für das Zuckerbudget.</span><span class="en">Language switch DE/EN, persisted. Later: meals per day, shopping rhythm, calorie reference for the sugar budget.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Einkaufshilfe</span><span class="en">Shopping aid</span></span>
<span class="badge p2">Phase 2</span>
</div>
<p><span class="de">Rechnet aus Einkaufsrhythmus und Mahlzeitenzahl den Bedarf je Kategorie und schlägt eine ausgewogene Liste vor. Abhakbar im Laden. Bedient primär David.</span><span class="en">Calculates per-category demand from shopping rhythm and meal count, then proposes a balanced list. Checkable in the shop. Primarily serves David.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Virtueller Kühlschrank</span><span class="en">Virtual fridge</span></span>
<span class="badge p2">Phase 2</span>
</div>
<p><span class="de">Erfasst, was da ist; bucht Zutaten ab, wenn gekocht wurde. Ermöglicht „was kann ich jetzt kochen?" und hilft, angebrochene Packungen über mehrere Tage aufzubrauchen (z.&nbsp;B. eine Dose Bohnen). Braucht Persistenz — technisch die erste echte Hürde. Später: speist die Chip-Auswahl im Teller-Builder direkt aus dem Bestand statt aus der ganzen Vorratskammer — dieselbe Bewertungslogik, andere Datenquelle.</span><span class="en">Records what's available; deducts ingredients once a meal is cooked. Enables "what can I make right now?" and helps use up opened items across several days (e.g. a can of beans). Requires persistence — the first real technical hurdle. Later: feeds the plate builder's chip choices straight from stock instead of the whole pantry — same scoring logic, different data source.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Wunschliste</span><span class="en">Wishlist</span></span>
<span class="badge p2">Phase 2</span>
</div>
<p><span class="de">Merkliste für Dinge, die in die Vorratskammer sollen, aber gerade fehlen — Bindeglied zwischen Kühlschrank und Einkaufsliste. Ohne eigene Logik: Einträge landen automatisch auf der nächsten Einkaufsliste.</span><span class="en">A running list of things that should be in the pantry but currently aren't — the link between the fridge and the shopping list. No logic of its own: entries land on the next shopping list automatically.</span></p>
</div>
<div class="mod">
<div class="mod-top">
<span class="mod-name"><span class="de">Konversationelle Manager</span><span class="en">Conversational managers</span></span>
<span class="badge p3">Phase 3</span>
</div>
<p><span class="de">Kühlschrank-Manager und Wochenplaner mit natürlicher Sprache („hab noch Spinat und Feta, was geht?"). Die einzige Stelle, an der Chat sinnvoll ist — weil die Eingabe zu vielfältig für Buttons wird. Braucht Backend.</span><span class="en">Fridge manager and weekly planner with natural language ("got spinach and feta left, what works?"). The only place chat makes sense — because input becomes too varied for buttons. Requires a backend.</span></p>
</div>
</div>
<h3><span class="de">Zusammenspiel der Module — der Kreislauf</span><span class="en">How the modules interlock — the loop</span></h3>
<p>
<span class="de">Aus dem FigJam-Board: Die Module sind kein loses Set, sie bilden einen geschlossenen Kreislauf. Jeder Schritt füttert den nächsten, der letzte Schritt füttert wieder den ersten.</span>
<span class="en">From the FigJam board: the modules aren't a loose set — they form a closed loop. Each step feeds the next, and the last step feeds back into the first.</span>
</p>
<div class="loop">
<div class="loop-row">
<div class="loop-node"><span class="de">Kühlschrank<span class="sub">kennt Bestand</span></span><span class="en">Fridge<span class="sub">knows stock</span></span></div>
<div class="loop-arrow"></div>
<div class="loop-node"><span class="de">Einkaufsliste</span><span class="en">Shopping list</span></div>
<div class="loop-arrow"></div>
<div class="loop-node"><span class="de">Wochenplan<span class="sub">mit Einkaufsliste verknüpft</span></span><span class="en">Meal plan<span class="sub">linked to shopping list</span></span></div>
<div class="loop-arrow"></div>
<div class="loop-node"><span class="de">Rezeptvorschläge</span><span class="en">Recipe suggestions</span></div>
<div class="loop-arrow"></div>
<div class="loop-node"><span class="de">„Hab Hunger, was jetzt?"</span><span class="en">"Hungry now, what's next?"</span></div>
</div>
<div class="loop-back">
<span class="icon"></span>
<span><span class="de">Die Antwort verbraucht Zutaten aus dem Bestand — und schreibt zurück in den Kühlschrank. Der Kreis schließt sich.</span><span class="en">The answer consumes ingredients from stock — and writes back into the fridge. The loop closes.</span></span>
</div>
<div class="loop-base">
<span class="icon"></span>
<span><span class="de">Die Knowledge Base (Teller-Regel, Vorratskammer, Daumenregeln) liegt unter jedem einzelnen Schritt — jedes Modul liest daraus, statt eigene Regeln mitzubringen.</span><span class="en">The knowledge base (plate rule, pantry, rules of thumb) underlies every single step — each module reads from it instead of carrying its own rules.</span></span>
</div>
</div>
<div class="callout info">
<b><span class="de">Konsequenz für die Roadmap</span><span class="en">Consequence for the roadmap</span></b>
<span class="de">Kühlschrank (Phase 2) ist der Dreh- und Angelpunkt des Kreislaufs, nicht nur ein Feature unter anderen. Einkaufshilfe und Vorschlagsmaschine bekommen ihren vollen Wert erst, wenn er steht. Phase 1 liefert die Module isoliert nutzbar vor — der Kreislauf selbst ist Phase 2.</span>
<span class="en">The fridge (Phase 2) is the pivot of the loop, not just one feature among others. The shopping aid and suggestion engine only reach their full value once it exists. Phase 1 ships the modules usable in isolation — the loop itself is Phase 2.</span>
</div>
</section>
<!-- 5 ARCHITEKTUR -->
<section>
<h2><span class="idx">05</span><span class="de">Architektur</span><span class="en">Architecture</span></h2>
<p class="lede">
<span class="de">Entscheidungen, die jetzt getroffen werden müssen, weil sie später teuer sind.</span>
<span class="en">Decisions that must be made now because they get expensive later.</span>
</p>
<h3><span class="de">Datenmodell: strukturiert statt Anzeige-Strings</span><span class="en">Data model: structured, not display strings</span></h3>
<p>
<span class="de">Der aktuelle Prototyp speichert fertige Anzeigetexte. Das blockiert sowohl Zweisprachigkeit als auch jede Rechenfunktion. Neue Form:</span>
<span class="en">The current prototype stores ready-made display text. That blocks both bilingualism and any computation. New shape:</span>
</p>
<pre><code>// Lebensmittel / food item
{ id: 'chicken_breast',
cat: 'protein',
name: { de: 'Hühnerbrust', en: 'Chicken breast' },
kcal: 111,
protein: 24,
tag: 'pick' } // 'pick' | 'caution' | null
// Rezept / recipe
{ id: 'eggs_cottage_cucumber',
name: { de: '3 Eier + Hüttenkäse + Gurke', en: '3 eggs + cottage cheese + cucumber' },
type: 'full', // 'full' | 'half'
times: ['midday','evening'],
uses: ['egg','cottage_cheese','cucumber','lentil_waffle'] }</code></pre>
<p>
<span class="de">Damit wird alles möglich: Übersetzen (<code>name[lang]</code>), Rechnen (Einkaufsmengen), Filtern (welches Rezept passt zum Vorrat), Verknüpfen (Rezept ↔ Zutat ↔ Kategorie).</span>
<span class="en">This unlocks everything: translation (<code>name[lang]</code>), computation (shopping quantities), filtering (which recipe fits the stock), linking (recipe ↔ ingredient ↔ category).</span>
</p>
<div class="callout ok">
<b><span class="de">Jetzt umstellen</span><span class="en">Migrate now</span></b>
<span class="de">Der Datensatz umfasst aktuell rund 70 Einträge. Bei 300 wird der Umbau zur Tagesaufgabe. Der Refactor gehört an den Anfang von Phase 1, nicht ans Ende.</span>
<span class="en">The dataset currently holds around 70 entries. At 300 the migration becomes a full day's work. The refactor belongs at the start of Phase 1, not the end.</span>
</div>
<h3><span class="de">Portabilität</span><span class="en">Portability</span></h3>
<p>
<span class="de">Ziel ist der spätere Umzug auf echtes Hosting. Damit das kein Neubau wird, gilt ab sofort: <strong>Daten, Logik und Darstellung strikt getrennt</strong>. Die Datenschicht bleibt reines JavaScript ohne DOM-Bezug — sie lässt sich unverändert in ein Repo übernehmen. Keine Abhängigkeit von Artifact-spezifischen APIs im Kern.</span>
<span class="en">The goal is an eventual move to real hosting. To keep that from becoming a rebuild, from now on: <strong>strictly separate data, logic and presentation</strong>. The data layer stays plain JavaScript with no DOM references — it can move into a repo untouched. No dependency on Artifact-specific APIs in the core.</span>
</p>
<div class="tablewrap">
<table>
<thead>
<tr>
<th><span class="de">Fähigkeit</span><span class="en">Capability</span></th>
<th>Artifact</th>
<th><span class="de">Eigenes Hosting</span><span class="en">Own hosting</span></th>
</tr>
</thead>
<tbody>
<tr><td><span class="de">Nachschlagen, Rechnen, Vorschläge</span><span class="en">Reference, computation, suggestions</span></td><td></td><td></td></tr>
<tr><td><span class="de">Home-Bildschirm-Icon</span><span class="en">Home screen icon</span></td><td><span class="de">über Safari „Teilen"</span><span class="en">via Safari "Share"</span></td><td></td></tr>
<tr><td><span class="de">Zustand speichern (Kühlschrank, Sprache)</span><span class="en">Persisted state (fridge, language)</span></td><td><span class="de">ungeprüft</span><span class="en">unverified</span></td><td></td></tr>
<tr><td><span class="de">Offline-Nutzung</span><span class="en">Offline use</span></td><td></td><td></td></tr>
<tr><td><span class="de">Konversationelle Module</span><span class="en">Conversational modules</span></td><td></td><td></td></tr>
<tr><td><span class="de">Sync zwischen zwei Nutzern</span><span class="en">Sync between two users</span></td><td></td><td></td></tr>
</tbody>
</table>
</div>
<h3><span class="de">Zweisprachigkeit</span><span class="en">Bilingualism</span></h3>
<p>
<span class="de">DE ist die primäre Arbeitssprache, EN die allgemeine Vorlage. Umsetzung nicht mehr über doppelte HTML-Blöcke wie bisher, sondern über ein <code>t()</code>-Lookup gegen das Datenmodell — sonst wächst jede Seite doppelt. UI-Texte kommen in ein eigenes Wörterbuch.</span>
<span class="en">DE is the primary working language, EN the general template. No longer implemented via duplicated HTML blocks, but through a <code>t()</code> lookup against the data model — otherwise every page doubles in size. UI strings move into their own dictionary.</span>
</p>
</section>
<!-- 6 ROADMAP -->
<section>
<h2><span class="idx">06</span>Roadmap</h2>
<p class="lede">
<span class="de">Phase 1 muss allein stehen können. Erst wenn sie im Alltag benutzt wird, kommt Phase 2.</span>
<span class="en">Phase 1 has to stand on its own. Phase 2 only follows once Phase 1 is actually used day to day.</span>
</p>
<h3><span class="de">Phase 1 — Fundament</span><span class="en">Phase 1 — foundation</span></h3>
<ul>
<li><span class="de">Datenmodell-Refactor: Anzeige-Strings → strukturierte Records mit IDs und <code>de</code>/<code>en</code></span><span class="en">Data model refactor: display strings → structured records with IDs and <code>de</code>/<code>en</code></span></li>
<li><span class="de">Settings-Seite mit Sprachumschaltung; <code>localStorage</code> im Artifact prüfen</span><span class="en">Settings page with language switch; verify <code>localStorage</code> inside the Artifact</span></li>
<li><span class="de">Knowledge Base auf das neue Modell umstellen (Teller-Regel + Vorratskammer + Daumenregeln)</span><span class="en">Migrate the knowledge base to the new model (plate rule + pantry + rules of thumb)</span></li>
<li><span class="de">Rezeptbibliothek aufbauen, Start mit den 13 Beispielmahlzeiten</span><span class="en">Build the recipe library, starting from the 13 example meals</span></li>
<li><span class="de">Vorschlagsmaschine: „Was esse ich jetzt?" nach Uhrzeit und Mahlzeitentyp</span><span class="en">Suggestion engine: "What do I eat now?" by time of day and meal type</span></li>
<li><span class="de">Teller-Builder: Lebensmittel pro Kategorie wählen, Kombination bewerten lassen</span><span class="en">Plate builder: pick foods per category, get the combination evaluated</span></li>
</ul>
<h3><span class="de">Phase 2 — Zustand &amp; Einkauf</span><span class="en">Phase 2 — state &amp; shopping</span></h3>
<ul>
<li><span class="de">Umzug auf eigenes Hosting, echtes PWA-Manifest, Offline-Fähigkeit</span><span class="en">Move to own hosting, real PWA manifest, offline capability</span></li>
<li><span class="de">Einkaufshilfe mit Kategorie-Rechner und abhakbarer Liste</span><span class="en">Shopping aid with category calculator and checkable list</span></li>
<li><span class="de">Virtueller Kühlschrank mit Abbuchung nach dem Kochen</span><span class="en">Virtual fridge with deduction after cooking</span></li>
<li><span class="de">Wunschliste als Bindeglied zwischen Kühlschrank und Einkaufsliste</span><span class="en">Wishlist as the link between fridge and shopping list</span></li>
<li><span class="de">Vorschläge nach Vorrat filtern</span><span class="en">Filter suggestions by available stock</span></li>
<li><span class="de">Datenmodell auf Basis „pro 100 g" normalisieren, Mengen in den Teller-Builder</span><span class="en">Normalise the data model to a "per 100 g" basis, add quantities to the plate builder</span></li>
<li><span class="de">Teller-Builder-Analyse: Kalorien, Makros, Vielfalt, Zucker; Favoriten speichern → Rezeptbibliothek</span><span class="en">Plate builder analysis: calories, macros, variety, sugar; save favourites → recipe library</span></li>
<li><span class="de">UI-Politur: von funktionalem Prototyp zu hochwertig wirkender App — mit dir als UX-Designerin</span><span class="en">UI polish: from functional prototype to an app that feels high-quality — with you leading as UX designer</span></li>
</ul>
<h3><span class="de">Phase 3 — Agentische Module</span><span class="en">Phase 3 — agentic modules</span></h3>
<ul>
<li><span class="de">Kühlschrank-Manager und Wochenplaner mit natürlicher Sprache</span><span class="en">Fridge manager and weekly planner with natural language</span></li>
<li><span class="de">Rezeptvorschläge, die über den kuratierten Bestand hinausgehen</span><span class="en">Recipe suggestions beyond the curated set</span></li>
<li><span class="de">Mehrbenutzer-Betrieb mit geteiltem Haushalt</span><span class="en">Multi-user operation with a shared household</span></li>
</ul>
</section>
<!-- 7 OFFENE FRAGEN -->
<section>
<h2><span class="idx">07</span><span class="de">Offene Fragen</span><span class="en">Open questions</span></h2>
<p class="lede">
<span class="de">Zu klären, bevor die jeweilige Phase startet.</span>
<span class="en">To be resolved before the respective phase starts.</span>
</p>
<ol class="qlist">
<li>
<span class="de">Speichert <code>localStorage</code> im Artifact-iframe zuverlässig? Im normalen Browser bestätigt (Sprache übersteht Reload), im claude.ai-iframe selbst noch nicht getestet. App zeigt in den Settings ehrlich an, ob's geklappt hat.</span>
<span class="en">Does <code>localStorage</code> persist reliably inside the Artifact iframe? Confirmed in a normal browser (language survives reload), not yet verified inside the claude.ai iframe itself. The app's settings screen honestly reports whether it worked.</span>
<span class="who"><span class="de">Steffi · auf dem iPhone prüfen</span><span class="en">Steffi · verify on iPhone</span></span>
</li>
<li>
<span class="de">Woher kommen neue Rezepte? Von Claude vorgeschlagen und von dir freigegeben, oder sammelst du selbst? Empfehlung: Claude schlägt vor, du kuratierst — sonst wächst die Bibliothek nicht.</span>
<span class="en">Where do new recipes come from? Proposed by Claude and approved by you, or collected yourself? Recommendation: Claude proposes, you curate — otherwise the library won't grow.</span>
<span class="who">Steffi</span>
</li>
<li>
<span class="de">Wie viele Rezepte braucht ein sinnvoller Start? Vorschlag: 3040, verteilt über die vier Tageszeiten.</span>
<span class="en">How many recipes make a viable start? Proposal: 3040, spread across the four times of day.</span>
<span class="who">Steffi</span>
</li>
<li>
<span class="de">Bekommt David eine eigene Instanz oder eine geteilte? Betrifft die Datenhaltung ab Phase 2.</span>
<span class="en">Does David get his own instance or a shared one? Affects data handling from Phase 2 onwards.</span>
<span class="who">Steffi</span>
</li>
<li>
<span class="de">Wohin beim Hosting — GitHub Pages, Vercel, eigene Domain? Entscheidet über Aufwand und Sync-Möglichkeiten.</span>
<span class="en">Where to host — GitHub Pages, Vercel, own domain? Determines effort and sync options.</span>
<span class="who"><span class="de">Steffi · vor Phase 2</span><span class="en">Steffi · before Phase 2</span></span>
</li>
<li>
<span class="de">Genügen gerundete Nährwerte? Die App ist keine medizinische Anwendung; PCOS und Prädiabetes gehören ins Coaching, nicht in eine App-Logik.</span>
<span class="en">Are rounded nutritional values sufficient? The app is not a medical tool; PCOS and prediabetes belong in the coaching, not in app logic.</span>
<span class="who">Steffi</span>
</li>
<li>
<span class="de">Lohnt sich eine Food-Database-API statt manueller Pflege? Open Food Facts (kostenlos, kein Key, gut für verpackte Produkte/Barcodes) vs. USDA FoodData Central (kostenlos, Key nötig, sehr genau für Grundzutaten, aber Englisch/US-zentriert) vs. Edamam (bester Rezept-Nährwert-Abgleich, aber Freemium mit Kontingent). Widerspricht evtl. dem Nicht-Ziel „kuratiert statt vollständig" — Integration würde eher die Vorratskammer ergänzen (Nährwerte nachschlagen), nicht ersetzen.</span>
<span class="en">Does a food-database API pay off over manual upkeep? Open Food Facts (free, no key, good for packaged products/barcodes) vs. USDA FoodData Central (free, needs a key, very accurate for base ingredients, but English/US-centric) vs. Edamam (best recipe-nutrition matching, but freemium with quota). May cut against the non-goal "curated, not exhaustive" — integration would more likely supplement the pantry (nutrition lookup) than replace it.</span>
<span class="who"><span class="de">Steffi · vor Phase 2</span><span class="en">Steffi · before Phase 2</span></span>
</li>
<li>
<span class="de">Wer treibt das UI-Redesign und wann im Prozess? Du bist UX-Designerin — heißt vermutlich: du lieferst Richtung/Mockups, Claude setzt um. Zeitpunkt: jetzt parallel zu Phase 1, oder erst wenn Funktionsumfang steht?</span>
<span class="en">Who drives the UI redesign and at what point in the process? You're the UX designer — likely means: you supply direction/mockups, Claude implements. Timing: now, in parallel with Phase 1, or only once the feature set is settled?</span>
<span class="who">Steffi</span>
</li>
<li>
<span class="de">Teller-Builder: Was genau macht eine Kombination „gut"? Vorschlag: alle 4 Kategorien besetzt = vollständig; enthält „pick"-Markierung = Bonus; enthält „caution" = Hinweis statt Fehler (nichts ist verboten). Genaue Bewertungsstufen und Formulierung sind UX-Entscheidung.</span>
<span class="en">Plate builder: what exactly makes a combination "good"? Proposal: all 4 categories filled = complete; contains a "pick" tag = bonus; contains a "caution" tag = a note, not an error (nothing is forbidden). Exact scoring tiers and wording are a UX call.</span>
<span class="who">Steffi</span>
</li>
<li>
<span class="de">Wie werden Mengen eingegeben — Stepper, Slider oder Zahlenfeld pro Chip? Vorschlag: Baukasten-Standardmenge vorausgefüllt, direkt editierbar. UX-Entscheidung.</span>
<span class="en">How are quantities entered — stepper, slider, or number field per chip? Proposal: pre-filled with the building-block default, directly editable. A UX call.</span>
<span class="who">Steffi</span>
</li>
<li>
<span class="de">Vielfalt über die Woche messen — braucht Verlauf gespeicherter Teller, nicht nur den aktuellen. Erst sinnvoll, sobald Favoriten/Historie existieren (Phase 2).</span>
<span class="en">Measuring variety across the week needs a history of saved plates, not just the current one. Only makes sense once favourites/history exist (Phase 2).</span>
<span class="who"><span class="de">Steffi · vor Umsetzung</span><span class="en">Steffi · before implementation</span></span>
</li>
<li>
<span class="de">Magic Button „Was esse ich jetzt?" — Konzept noch nicht ausgearbeitet. Braucht: Eingaben (Zeit, evtl. Kühlschrankinhalt, evtl. Stimmung/Zeitdruck), Ausgabelogik (ein Vorschlag vs. 3 Optionen), Verhalten bei „passt nicht" (neu würfeln? Filter?). Gehört zur Vorschlagsmaschine (Modul, Abschnitt 4), aber die Interaktion selbst ist UX-Arbeit.</span>
<span class="en">Magic Button "What can I eat now?" — concept not yet worked out. Needs: inputs (time, maybe fridge contents, maybe mood/time pressure), output logic (one suggestion vs. 3 options), behaviour on "doesn't fit" (reroll? filter?). Belongs to the suggestion engine (module, section 4), but the interaction itself is UX work.</span>
<span class="who"><span class="de">Steffi + Claude gemeinsam</span><span class="en">Steffi + Claude together</span></span>
</li>
</ol>
<div class="callout">
<b><span class="de">Rechtlicher Hinweis</span><span class="en">Legal note</span></b>
<span class="de">Zucker-Guide und Einkaufsguide tragen den Vermerk „Weitergabe ohne Genehmigung ist nicht gestattet". Die App baut inhaltlich auf diesem Material auf. Für den privaten Gebrauch unproblematisch — vor jeder Weitergabe, Veröffentlichung oder Nutzung durch David mit Pascal klären. Gleiches gilt für den Namen „Pocket Pascal".</span>
<span class="en">The sugar guide and shopping guide carry the notice "distribution without permission is not permitted". The app builds on this material. Unproblematic for private use — clear it with Pascal before any sharing, publication, or use by David. The same applies to the name "Pocket Pascal".</span>
</div>
</section>
<!-- 8 ENTSCHEIDUNGSLOG -->
<section>
<h2><span class="idx">08</span><span class="de">Entscheidungslog</span><span class="en">Decision log</span></h2>
<p class="lede">
<span class="de">Getroffene Festlegungen, damit sie nicht neu verhandelt werden.</span>
<span class="en">Settled decisions, so they don't get re-litigated.</span>
</p>
<div class="tablewrap">
<table>
<thead>
<tr>
<th><span class="de">Datum</span><span class="en">Date</span></th>
<th><span class="de">Entscheidung</span><span class="en">Decision</span></th>
<th><span class="de">Begründung</span><span class="en">Rationale</span></th>
</tr>
</thead>
<tbody>
<tr>
<td>2026-08-03</td>
<td><span class="de">Alle Artefakte zweisprachig, DE primär</span><span class="en">All artefacts bilingual, DE primary</span></td>
<td><span class="de">DE zum eigenen Anpassen, EN als allgemeine Vorlage</span><span class="en">DE for personal editing, EN as the general template</span></td>
</tr>
<tr>
<td>2026-08-03</td>
<td><span class="de">Buttons statt Chat als Standard</span><span class="en">Buttons over chat by default</span></td>
<td><span class="de">Offene Konversation kostet im Hunger-Moment zu viel Zeit</span><span class="en">Open conversation costs too much time in a moment of hunger</span></td>
</tr>
<tr>
<td>2026-08-03</td>
<td><span class="de">Später eigenes Hosting, portable Architektur ab jetzt</span><span class="en">Own hosting later, portable architecture from now</span></td>
<td><span class="de">Kühlschrank und AI-Module sind im Artifact nicht umsetzbar</span><span class="en">Fridge and AI modules aren't feasible inside an Artifact</span></td>
</tr>
<tr>
<td>2026-08-03</td>
<td><span class="de">Datenmodell strukturiert statt Anzeige-Strings</span><span class="en">Structured data model instead of display strings</span></td>
<td><span class="de">Voraussetzung für Übersetzung, Rechnen und Filtern</span><span class="en">Prerequisite for translation, computation and filtering</span></td>
</tr>
<tr>
<td>2026-08-03</td>
<td><span class="de">App nur hell, Dokumente hell/dunkel</span><span class="en">App light only, documents light/dark</span></td>
<td><span class="de">Küchennutzung bei Tageslicht, Druckvorlagen brauchen Weiß</span><span class="en">Kitchen use in daylight, print masters need white</span></td>
</tr>
<tr>
<td>2026-08-03</td>
<td><span class="de">Phase 1 = Knowledge Base + Rezeptbibliothek + Vorschlag</span><span class="en">Phase 1 = knowledge base + recipe library + suggestion</span></td>
<td><span class="de">Bedient Steffis Kernbedürfnis, ohne Persistenz auszukommen</span><span class="en">Serves Steffi's core need without requiring persistence</span></td>
</tr>
<tr>
<td>2026-08-03</td>
<td><span class="de">Testen/Aktualisieren: Artifact-Link direkt auf dem iPhone öffnen, kein Neu-Hinzufügen zum Home-Bildschirm nötig</span><span class="en">Testing/updating: open the Artifact link directly on the iPhone, no need to re-add to the home screen</span></td>
<td><span class="de">Ohne Service Worker cacht iOS nichts offline — jeder Aufruf lädt die zuletzt veröffentlichte Version live nach; das Home-Bildschirm-Icon zeigt also automatisch den neuesten Stand</span><span class="en">Without a service worker, iOS caches nothing offline — every open fetches the latest published version live, so the home-screen icon automatically shows the newest state</span></td>
</tr>
<tr>
<td>2026-08-03</td>
<td><span class="de">Web/PWA statt native iOS-App (kein Swift/Cursor)</span><span class="en">Web/PWA instead of a native iOS app (no Swift/Cursor)</span></td>
<td><span class="de">Nur 2 Nutzer (Steffi + David); bestehendes Datenmodell/Logik direkt weiterverwendbar statt Neustart der Codebasis; kein App-Store-Aufwand nötig</span><span class="en">Only 2 users (Steffi + David); existing data model/logic carries over directly instead of a codebase restart; no App Store overhead needed</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<footer class="doc">
<span class="de">Pocket Pascal · Arbeitsdokument, Stand 2026-08-03 · Inhaltliche Grundlage: Ernährungsbaukasten, Einkaufsguide und Zucker-Guide von Pascal Wirth</span>
<span class="en">Pocket Pascal · working document, as of 2026-08-03 · Content basis: building-block system, shopping guide and sugar guide by Pascal Wirth</span>
</footer>
</div>

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import { GlassCard } from "./components/GlassCard";
import { Button } from "./components/Button";
import { BottomNav, type NavItem } from "./components/BottomNav";
import { PlateChart } from "./components/PlateChart";
/**
* Demo page — every component rendered over a few different background
* gradients so the backdrop-blur actually has something to refract.
* Drop this on a route in Cursor (e.g. /showcase) to sanity-check the
* system before wiring it into real screens.
*/
export default function ComponentShowcase() {
const [dark, setDark] = useState(false);
const [active, setActive] = useState("plate");
const [loading, setLoading] = useState(false);
const navItems: NavItem[] = [
{ key: "plate", icon: "🍽️", label: "Teller", active: active === "plate", onClick: () => setActive("plate") },
{ key: "pantry", icon: "🧺", label: "Vorrat", active: active === "pantry", onClick: () => setActive("pantry") },
{ key: "builder", icon: "🧩", label: "Builder", active: active === "builder", onClick: () => setActive("builder") },
{ key: "settings", icon: "⚙️", label: "Mehr", active: active === "settings", onClick: () => setActive("settings") },
];
return (
<div className={dark ? "dark" : ""}>
<div
className="min-h-screen font-body"
style={{ background: "var(--page-gradient)", color: "var(--ink)" }}
>
<div className="mx-auto max-w-md space-y-10 px-5 pb-32 pt-10">
<header className="flex items-center justify-between">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[var(--ink-faint)]">
Pocket Pascal · Component Showcase
</p>
<h1 className="font-display text-3xl font-black">Elevated Surfaces</h1>
</div>
<Button variant="secondary" onClick={() => setDark((d) => !d)}>
{dark ? "☀️ Light" : "🌙 Dark"}
</Button>
</header>
{/* GlassCard variants */}
<section className="space-y-3">
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
Card
</h2>
<GlassCard className="p-5">
<p className="font-display text-lg font-bold">Default</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">
Static container low-transparency surface, real elevation shadow, raised bevel edge.
</p>
</GlassCard>
<GlassCard variant="interactive" className="p-5" onClick={() => {}}>
<p className="font-display text-lg font-bold">Interactive</p>
<p className="mt-1 text-sm text-[var(--ink-soft)]">Tap me settles down 1px, shadow softens.</p>
</GlassCard>
</section>
{/* Dining Plate chart inside a GlassCard */}
<section className="space-y-3">
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
Dining Plate Chart
</h2>
<GlassCard className="flex flex-col items-center gap-4 p-6">
<PlateChart
size={220}
centerValue="420"
centerLabel="kcal"
slices={[
{ id: "protein", label: "Protein", value: 30, colors: ["#D9F227", "#B8CE1E"] },
{ id: "obstgem", label: "Obst/Gemüse", value: 30, colors: ["#FF6A3D", "#FF8F6B"] },
{ id: "fett", label: "Fett", value: 15, colors: ["#FFB300", "#FFC94A"] },
{ id: "kh", label: "KH", value: 25, colors: ["#1FD9C4", "#5FE0C4"] },
]}
/>
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs font-bold text-[var(--ink-soft)]">
<span>🟡 Protein 30%</span>
<span>🟠 Obst/Gemüse 30%</span>
<span>🟨 Fett 15%</span>
<span>🟢 KH 25%</span>
</div>
</GlassCard>
</section>
{/* Buttons */}
<section className="space-y-3">
<h2 className="font-display text-sm font-bold uppercase tracking-wide text-[var(--ink-faint)]">
Buttons
</h2>
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary">Guter Teller!</Button>
<Button variant="secondary">Details</Button>
<Button variant="fab" icon="" aria-label="Hinzufügen" />
</div>
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" loading={loading} onClick={() => setLoading((l) => !l)}>
{loading ? "Speichert…" : "Speichern (Loading toggle)"}
</Button>
<Button variant="secondary" disabled>
Disabled
</Button>
</div>
</section>
</div>
<BottomNav items={navItems} />
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
# Pocket Pascal — Elevated Surface Component Library
Drop this folder's contents into your Cursor React + TypeScript + Tailwind project. It's framework-agnostic (no Next.js-specific imports), works in Vite or Next.
> **Pivoted from glassmorphism to elevation (2026-08-03).** The first pass used real see-through glass (12% white fill, 16px blur) — over a plain white/dark background it barely reads as anything, which is exactly the feedback that came back: "I don't see the glass effect." Rather than fight a page background that has nothing distinct behind it to refract, the system now uses **low-transparency solid surfaces (~90% opaque) with visible elevation shadows**, and buttons that visibly **press inward** on tap (inset shadow + 1px downward shift + slight darkening) instead of just scaling down. The `--glass-*` variable names in `theme.css` were kept to minimize the diff across components, but they now mean "surface fill / surface blur," not transparent glass — see the comment block at the top of `theme.css`.
## Setup
1. **Copy files**
- `styles/theme.css` → your global stylesheet, imported once in your app root (e.g. `main.tsx` or `app/layout.tsx`), **after** `@tailwind base/components/utilities`.
- `lib/cx.ts`, `components/*.tsx` → into your `src/` at matching paths, or adjust the relative imports.
- `public/fonts/*.woff2` → your project's `public/fonts/` (already the correct static latin-subset files, no need to re-fetch from Google).
- `ComponentShowcase.tsx` → mount on a scratch route to sanity-check everything renders before wiring into real screens.
2. **Tailwind config** — enable class-based dark mode so the `.dark` class in `theme.css` works:
```ts
// tailwind.config.ts
export default {
darkMode: "class",
content: ["./src/**/*.{ts,tsx}"],
theme: { extend: {} },
};
```
No plugin needed — `backdrop-filter`, `active:`, and arbitrary values (`bg-[image:var(--x)]`) are all core Tailwind v3+. This library leans on CSS variables + inline `style` for the gradient/blur values rather than baking them into the Tailwind theme, so it works the same whether or not you extend the config further.
3. **Toggle dark mode** by adding/removing the `dark` class on `<html>` (or any ancestor — see `ComponentShowcase.tsx` for a self-contained example that wraps in a `div` instead, for demo purposes only; in a real app put it on `<html>`).
## What's in here
| File | Purpose |
|---|---|
| `styles/theme.css` | All design tokens: surface fill/blur/bevel-border, three-state shadows (resting/elevated/pressed), brand colors, surface + action gradients, `@font-face`, the `.glass-border` masked-bevel utility, PWA polish resets. |
| `components/GlassCard.tsx` | Primary container. `variant="default" \| "interactive"`. Real elevation shadow, settles down 1px on tap. |
| `components/BottomNav.tsx` | Floating pill nav, safe-area aware, gradient-on-active-icon, items press inward on tap. |
| `components/Button.tsx` | `variant="primary" \| "secondary" \| "fab"`, plus `loading`/`disabled` states. Press state = inset shadow + 1px shift + darken, not just scale. |
| `components/PlateChart.tsx` | SVG donut styled as a ceramic plate (rim + recessed well + gapped gradient slices + center label). |
| `ComponentShowcase.tsx` | Demo page rendering all of the above over the ambient page gradient, with a light/dark toggle. |
## Deliberate deviations from the original brief — and why
**Text color on gradient buttons/badges is `--ink` (near-black), not white.**
The brand's action gradients (lime → teal, coral → amber) are light/high-luminance by design — they're the same tokens already WCAG-audited for the rest of the Pocket Pascal system (see `/docs/CONCEPT.html`, decision log). White text on them fails AA contrast outright; a text-shadow doesn't fix a 2:1 ratio. Dark `--ink` text on these gradients measures 5.610.6:1 in both themes. If you introduce a new, genuinely dark gradient (e.g. for a destructive/caution CTA), white text + `text-shadow: 0 1px 2px rgba(0,0,0,0.25)` is fine there — just check the ratio first.
**`--gradient-caution` was added** (coral → deep red) for destructive actions, since the brief's two named gradients (`primary`, `accent`) didn't cover that case and the brand already has a `--color-caution` token.
**Fonts are loaded via local `@font-face` + static `.woff2` files, not `next/font` or a Google Fonts `<link>`.** Keeps `theme.css` portable across Vite/Next, and avoids a runtime dependency on Google's CDN. The five files in `public/fonts/` are the exact latin-subset statics used everywhere else in this project.
**`.glass-border` uses a masked pseudo-gradient, not `border-image`.** `border-image` ignores `border-radius` — it would square off every rounded corner. The mask-composite technique in `theme.css` respects `border-radius: inherit`, so it works on the 3xl card, the pill nav, and the 2xl buttons without extra per-component CSS.
**Pressed state is inset-shadow + 1px shift + darken, applied via `active:` Tailwind classes referencing CSS vars (`active:shadow-[var(--shadow-pressed)]`), not JS state or `active:scale`.** Scale alone reads as "shrinking," not "pushed in" — the eye needs the shadow to flip from outside (elevated) to inside (pressed) to sell the depth illusion. `Button` and the `BottomNav` items use this; `GlassCard`'s interactive variant deliberately uses a milder version (settle 1px + soften shadow, no inset) since a card is a container you tap *into*, not a switch you press.
## Known follow-ups (not done here)
- `PlateChart` gap is computed from a **physical pixel gap → angular gap** at a fixed average radius; if you resize the chart a lot at runtime, re-check that 2px still reads as a visible gap at very small sizes (below ~120px it may visually disappear).
- No unit/visual regression tests included — this is a first-pass scaffold for Cursor, not a hardened library.
- `BottomNav` active-icon gradient uses `background-clip: text`, which needs `-webkit-background-clip: text` too in some older WebKit builds; not added here since Tailwind's `bg-clip-text` utility already handles the prefix.

View File

@@ -0,0 +1,88 @@
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 Glass Pill bottom navigation.
*
* - Fixed to the bottom, centered, floats above content (not edge-to-edge).
* - Respects `env(safe-area-inset-bottom)` so it clears the iOS home indicator.
* - Active item gets the accent gradient applied directly to icon + label
* (bg-clip-text) rather than a pill behind it, so it reads against the
* glass background instead of competing with it.
*/
export function BottomNav({ items, className }: BottomNavProps) {
return (
<nav
className={cx("fixed inset-x-0 bottom-0 z-50 flex justify-center pointer-events-none", className)}
style={{ paddingBottom: "calc(env(safe-area-inset-bottom) + 12px)" }}
>
<div
className="relative flex items-center gap-1 rounded-full px-2 py-2 pointer-events-auto"
style={{ boxShadow: "var(--shadow-ambient)" }}
>
<div
className="absolute inset-0 rounded-full"
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-full" />
{items.map((item) => {
const content = (
<>
<span
aria-hidden
className={cx(
"text-xl leading-none",
item.active ? "bg-clip-text text-transparent" : "text-[var(--ink-soft)]"
)}
style={item.active ? { backgroundImage: "var(--gradient-accent)" } : undefined}
>
{item.icon}
</span>
<span
className={cx(
"font-body text-[10px] font-bold leading-none",
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-w-[48px] min-h-[48px] flex-col items-center justify-center gap-0.5",
"elevation-transition rounded-full px-3 no-select 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,112 @@
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-white/40 border-t-white"
)}
/>
);
}
/**
* Shared press behavior: 1px downward shift + swap from elevated to an
* inset shadow + slight darkening. This is what actually reads as
* "physically pushed in" — scale alone doesn't sell it, the inset shadow
* does the work.
*/
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",
"font-body font-bold 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 color rule: our brand action gradients (lime→teal, coral→amber) are
* light/high-luminance by design — see theme.css. Buttons use --ink text on
* them, not white, to stay at WCAG AA without a text-shadow crutch. The
* `secondary` variant uses --ink for the same reason (low-transparency
* surface fill, not a dark background).
*/
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-full 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,
"min-h-[48px] overflow-hidden rounded-2xl px-6 text-[var(--ink)]",
className
)}
{...props}
>
<span
className="absolute inset-0 rounded-2xl"
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-2xl" />
<span className="relative z-10 flex items-center gap-2">
{loading ? <Spinner dark /> : children}
</span>
</button>
);
}
// primary
return (
<button
ref={ref}
disabled={isDisabled}
className={cx(base, pressable, "min-h-[48px] rounded-2xl 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,62 @@
import { forwardRef, type HTMLAttributes } from "react";
import { cx } from "../lib/cx";
export interface GlassCardProps extends HTMLAttributes<HTMLDivElement> {
/**
* `default` — static, sits at rest elevation.
* `interactive` — lifts on hover-capable devices, settles down 1px + shadow
* softens on press (not a full inward "pressed" look — that's reserved
* for `Button`; a card is a container you tap into, not a switch).
*/
variant?: "default" | "interactive";
}
/**
* Elevated Surface Card — the primary container of the design system.
*
* Low-transparency (not see-through glass): a solid-reading surface fill
* (~90% opaque) with a visible drop shadow for real elevation, plus a faint
* top-light/bottom-shadow bevel on the edge so it reads as raised off the
* page rather than flat or floating-glass.
*
* Structure (three stacked layers, all inside one rounded-3xl shell):
* 1. Base: subtle surface gradient (--surface-gradient) for texture.
* 2. Surface fill: ~90% opaque fill + 8px blur (softens whatever's behind
* it without reading as transparent) + masked bevel border.
* 3. Content: rendered above both, in normal flow.
*/
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 rounded-3xl no-select",
"shadow-[var(--shadow-elevated)]",
interactive && "cursor-pointer active:translate-y-px active:shadow-[var(--shadow-resting)]",
className
)}
style={{ backgroundImage: "var(--surface-gradient)" }}
{...props}
>
{/* surface fill + blur */}
<div
className="absolute inset-0 rounded-3xl"
style={{
background: "var(--glass-fill)",
backdropFilter: "blur(var(--glass-blur))",
WebkitBackdropFilter: "blur(var(--glass-blur))",
}}
/>
{/* bevel border, 1px, radius-safe */}
<div className="glass-border pointer-events-none absolute inset-0 rounded-3xl" />
{/* content */}
<div className="relative z-10">{children}</div>
</div>
);
}
);
GlassCard.displayName = "GlassCard";

View File

@@ -0,0 +1,121 @@
import { useId } from "react";
export interface PlateSlice {
id: string;
label: string;
value: number;
/** 2-stop gradient for this slice, e.g. ["#D9F227", "#1FD9C4"]. */
colors: [string, string];
}
export interface PlateChartProps {
slices: PlateSlice[];
size?: number;
centerLabel?: string;
centerValue?: string;
/** Physical gap between slices in px, converted to an angular gap at render time. */
gapPx?: number;
}
function polarToCartesian(cx: number, cy: number, r: number, angleDeg: number) {
const rad = ((angleDeg - 90) * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
/** Donut-segment path between two radii, from startAngle to endAngle (degrees, 0 = top). */
function arcPath(cx: number, cy: number, rOuter: number, rInner: number, startAngle: number, endAngle: number) {
const startOuter = polarToCartesian(cx, cy, rOuter, endAngle);
const endOuter = polarToCartesian(cx, cy, rOuter, startAngle);
const startInner = polarToCartesian(cx, cy, rInner, endAngle);
const endInner = polarToCartesian(cx, cy, rInner, startAngle);
const largeArc = endAngle - startAngle <= 180 ? 0 : 1;
return [
"M", startOuter.x, startOuter.y,
"A", rOuter, rOuter, 0, largeArc, 0, endOuter.x, endOuter.y,
"L", endInner.x, endInner.y,
"A", rInner, rInner, 0, largeArc, 1, startInner.x, startInner.y,
"Z",
].join(" ");
}
/**
* "Dining Plate" chart — a donut styled to read as a ceramic plate: outer
* rim (gradient stroke), recessed well (radial gradient), slices as
* gradient-filled donut segments with a small gap between each, and a
* center overlay for the headline metric.
*
* Meant to live inside a `GlassCard`. All visuals are gradients — no images.
*/
export function PlateChart({ slices, size = 240, centerLabel, centerValue, gapPx = 2 }: PlateChartProps) {
const uid = useId().replace(/:/g, "");
const cx = size / 2;
const cy = size / 2;
const rimR = size / 2 - 4;
const outerR = rimR - 14;
const innerR = size * 0.32;
const total = slices.reduce((sum, s) => sum + s.value, 0) || 1;
const avgR = (outerR + innerR) / 2;
const gapDeg = avgR > 0 ? (gapPx / (avgR * 2 * Math.PI)) * 360 : 0;
let angle = 0;
const segments = slices
.filter((s) => s.value > 0)
.map((slice) => {
const sweep = (slice.value / total) * 360;
const start = angle + gapDeg / 2;
const end = angle + sweep - gapDeg / 2;
angle += sweep;
return { ...slice, start, end, d: end > start ? arcPath(cx, cy, outerR, innerR, start, end) : null };
});
return (
<div className="relative select-none" style={{ width: size, height: size }}>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label="Plate breakdown">
<defs>
<radialGradient id={`${uid}-well`} cx="50%" cy="50%" r="65%">
<stop offset="0%" stopColor="var(--plate-well-center)" />
<stop offset="100%" stopColor="var(--plate-well-edge)" />
</radialGradient>
<linearGradient id={`${uid}-rim`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="var(--plate-rim-light)" />
<stop offset="100%" stopColor="var(--plate-rim-shadow)" />
</linearGradient>
{segments.map((s) => (
<linearGradient key={s.id} id={`${uid}-slice-${s.id}`} x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor={s.colors[0]} />
<stop offset="100%" stopColor={s.colors[1]} />
</linearGradient>
))}
</defs>
{/* ceramic rim */}
<circle cx={cx} cy={cy} r={rimR} fill="none" stroke={`url(#${uid}-rim)`} strokeWidth={6} />
{/* recessed well */}
<circle cx={cx} cy={cy} r={rimR - 8} fill={`url(#${uid}-well)`} />
{segments.map(
(s) => s.d && <path key={s.id} d={s.d} fill={`url(#${uid}-slice-${s.id})`} />
)}
{/* center hole, matches well so the label sits on clean ground */}
<circle cx={cx} cy={cy} r={innerR - 6} fill={`url(#${uid}-well)`} />
</svg>
{(centerLabel || centerValue) && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
{centerValue && (
<span className="font-display text-2xl font-black leading-none text-[var(--ink)]">
{centerValue}
</span>
)}
{centerLabel && (
<span className="font-body mt-1 text-[11px] font-bold uppercase tracking-wide text-[var(--ink-soft)]">
{centerLabel}
</span>
)}
</div>
)}
</div>
);
}

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,192 @@
/**
* 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);
/* ---------- Shadows (three states: resting, elevated/lifted, pressed-in) ---------- */
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.06), 0 2px 6px rgba(0, 0, 0, 0.06);
--shadow-elevated: 0 4px 10px rgba(0, 0, 0, 0.10), 0 14px 28px rgba(0, 0, 0, 0.10);
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.28), inset 0 1px 1px rgba(0, 0, 0, 0.15);
/* kept for anything still referencing the old name */
--shadow-ambient: var(--shadow-elevated);
/* ---------- Brand ink / neutrals ---------- */
--ink: #191800;
--ink-soft: #5c5b48;
--ink-faint: #6b6a56;
--border: #e2e8f0;
/* ---------- 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;
/* ---------- 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.10);
--glass-border-to: rgba(0, 0, 0, 0.35);
--shadow-resting: 0 1px 2px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.3);
--shadow-elevated: 0 4px 10px rgba(0, 0, 0, 0.45), 0 16px 32px rgba(0, 0, 0, 0.4);
--shadow-pressed: inset 0 2px 5px rgba(0, 0, 0, 0.6), inset 0 1px 1px rgba(0, 0, 0, 0.3);
--shadow-ambient: var(--shadow-elevated);
--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;
}
/* ---------- 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;
}

1332
handover/data/foods.json Normal file

File diff suppressed because it is too large Load Diff

66
handover/data/steps.json Normal file
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 & Gemüse",
"en": "Fruit & Veggie"
},
"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)."
}
}
]

View File

@@ -0,0 +1,836 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Baukasten">
<meta name="theme-color" content="#ffffff">
<title>Pocket Pascal</title>
<style>
:root{
--bg:#ffffff; --surface:#ffffff; --ink:#111111; --ink-soft:#5c5c5c; --line:#e6e6e6;
--green:#2F5233; --green-soft:#e3ece4;
--sage:#4A6FA5; --sage-soft:#e7edf5;
--terracotta:#B33D22; --terracotta-soft:#f7e0d8; --terracotta-text:#A83B22;
--leaf:#A8C23E; --leaf-soft:#f2f5df; --leaf-text:#5C6B1E;
--gold:#E8B93A; --gold-soft:#fdf1d9; --gold-text:#8A6A1A;
--slate:#6B4C9A; --slate-soft:#efe9f5;
--pick:#16A34A; --caution:#DC2626;
}
*{box-sizing:border-box; -webkit-tap-highlight-color:transparent;}
html,body{ height:100%; }
body{
margin:0; background:var(--bg); color:var(--ink);
font-family: ui-sans-serif, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-variant-numeric: tabular-nums;
padding-top:env(safe-area-inset-top); padding-bottom:env(safe-area-inset-bottom);
overscroll-behavior-y: contain;
}
.display{ font-family:"Futura","Century Gothic","Avenir Next","Avenir",ui-sans-serif,sans-serif; font-weight:800; letter-spacing:-0.01em; }
/* ---------- top bar ---------- */
.topbar{
position:sticky; top:0; z-index:10; background:var(--surface);
border-bottom:1px solid var(--line);
display:flex; align-items:center; gap:8px;
padding:14px 16px calc(14px + env(safe-area-inset-top)) 16px;
margin-top:calc(-1 * env(safe-area-inset-top));
padding-top:calc(14px + env(safe-area-inset-top));
}
.backbtn{
display:none; border:none; background:var(--bg); color:var(--ink);
width:34px; height:34px; border-radius:50%; font-size:1.1rem;
align-items:center; justify-content:center; flex:none;
}
.backbtn.show{ display:flex; }
.tb-title{ flex:1; min-width:0; }
.topbar h1{ font-size:1.05rem; margin:0; font-weight:900; text-transform:uppercase; letter-spacing:0.02em; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.topbar .eyebrow{ font-size:0.62rem; text-transform:uppercase; letter-spacing:0.1em; color:var(--ink-soft); font-weight:700; display:block; }
.tb-actions{ flex:none; display:flex; align-items:center; gap:8px; }
.langpill{
display:flex; gap:2px; background:var(--bg); border:1px solid var(--line);
border-radius:999px; padding:2px;
}
.langpill button{
border:none; background:transparent; padding:5px 9px; border-radius:999px;
font-size:0.66rem; font-weight:800; letter-spacing:0.04em; color:var(--ink-soft);
}
.langpill button.active{ background:var(--green); color:#fff; }
.gearbtn{
border:none; background:var(--bg); width:30px; height:30px; border-radius:50%;
font-size:0.95rem; display:flex; align-items:center; justify-content:center; flex:none;
}
main{ padding:16px; padding-bottom:calc(32px + env(safe-area-inset-bottom)); max-width:640px; margin:0 auto; }
.view{ display:none; }
.view.active{ display:block; }
/* ---------- home grid ---------- */
.homegrid{
display:grid; grid-template-columns:repeat(2,1fr); gap:12px;
grid-auto-rows:minmax(120px,auto);
}
.tile{
border:1px solid var(--line); border-radius:14px; overflow:hidden;
display:flex; flex-direction:column; justify-content:space-between;
padding:16px; text-align:left; background:var(--surface);
-webkit-user-select:none; user-select:none;
}
.tile .ic{ font-size:2rem; line-height:1; }
.tile .t{ font-size:0.95rem; font-weight:900; text-transform:uppercase; letter-spacing:0.01em; margin-top:10px; }
.tile .d{ font-size:0.7rem; color:var(--ink-soft); margin-top:2px; }
.tile.soon{ opacity:0.4; }
.tile.teller{ border-top:4px solid var(--green); }
.tile.vorrat{ border-top:4px solid var(--terracotta); }
.tile.settings{ border-top:4px solid var(--slate); }
.tile.builder{ border-top:4px solid var(--pick); }
.tile.soon{ border-top:4px solid var(--line); }
/* ---------- plate toggle ---------- */
.segctl{
display:flex; background:var(--green-soft); border-radius:999px; padding:3px; margin-bottom:16px;
}
.segctl button{
flex:1; border:none; background:transparent; padding:9px 4px; border-radius:999px;
font-size:0.78rem; font-weight:700; color:var(--ink-soft);
}
.segctl button.active{ background:var(--surface); color:var(--ink); box-shadow:0 1px 3px rgba(0,0,0,0.12); }
.plate-wrap{ display:flex; flex-direction:column; align-items:center; gap:10px; margin-bottom:18px; }
.plate{ width:150px; height:150px; border-radius:50%; position:relative; box-shadow:inset 0 0 0 1px var(--line); }
.plate::after{ content:""; position:absolute; inset:22%; background:var(--surface); border-radius:50%; box-shadow:inset 0 0 0 1px var(--line); }
.legend{ display:flex; flex-direction:column; gap:4px; font-size:0.78rem; color:var(--ink-soft); }
.legend span{ display:flex; align-items:center; gap:6px; justify-content:center; }
.dot{ width:8px; height:8px; border-radius:50%; flex:none; }
.dot.g{background:var(--green);} .dot.t{background:var(--terracotta);} .dot.o{background:var(--gold);} .dot.s{background:var(--slate);}
/* ---------- accordion (steps + categories) ---------- */
.acc{ border:1px solid var(--line); border-radius:10px; overflow:hidden; margin-bottom:10px; }
.acc-head{
display:flex; align-items:center; gap:10px; padding:13px 14px;
background:var(--surface); border:none; width:100%; text-align:left;
}
.acc-head .num{ flex:none; width:26px; height:26px; border-radius:50%; color:#fff; font-weight:700; font-size:0.8rem; display:flex; align-items:center; justify-content:center; }
.acc-head .num.g{background:var(--green);} .acc-head .num.t{background:var(--terracotta);} .acc-head .num.o{background:var(--gold); color:#111;} .acc-head .num.s{background:var(--slate);}
.acc-head .ic{ flex:none; font-size:1.3rem; width:26px; text-align:center; }
.acc-head .title{ flex:1; font-weight:800; font-size:0.86rem; text-transform:uppercase; letter-spacing:0.02em; }
.acc-head .qty{ font-size:0.72rem; color:var(--ink-soft); font-weight:600; }
.acc-head .count{ font-size:0.7rem; color:var(--ink-soft); background:var(--bg); border-radius:20px; padding:2px 9px; flex:none; }
.acc-head .chev{ flex:none; font-size:0.9rem; color:var(--ink-soft); transition:transform 0.15s ease; }
.acc.open .chev{ transform:rotate(90deg); }
.acc-body{ display:none; padding:0 14px 14px; border-top:1px solid var(--line); }
.acc.open .acc-body{ display:block; padding-top:10px; }
.acc-body p{ margin:0; font-size:0.82rem; color:var(--ink-soft); line-height:1.5; }
.cat-head{ border-left-width:4px; border-left-style:solid; }
.acc.protein .cat-head{ border-left-color:var(--green); }
.acc.snack .cat-head{ border-left-color:var(--sage); }
.acc.obst .cat-head{ border-left-color:var(--terracotta); }
.acc.gemuese .cat-head{ border-left-color:var(--leaf); }
.acc.fett .cat-head{ border-left-color:var(--gold); }
.acc.kh .cat-head{ border-left-color:var(--slate); }
table{ width:100%; border-collapse:collapse; font-size:0.82rem; margin-top:6px; }
table td{ padding:6px 0; border-bottom:1px dashed var(--line); vertical-align:top; }
table td:last-child{ text-align:right; color:var(--ink-soft); white-space:nowrap; padding-left:8px; }
tr:last-child td{ border-bottom:none; }
.approx{ font-style:italic; }
.dotmark{ font-size:0.7em; margin-right:3px; }
.dotmark.pick{ color:var(--pick); }
.dotmark.caution{ color:var(--caution); }
.subhead{ font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--ink-soft); margin:10px 0 0; }
.rot{ margin-top:10px; padding-top:10px; border-top:1px dashed var(--line); font-size:0.78rem; line-height:1.45; }
.rot b{ text-transform:uppercase; letter-spacing:0.03em; font-size:0.7rem; }
.acc.protein .rot b{ color:var(--green); } .acc.snack .rot b{ color:var(--sage); }
.acc.obst .rot b{ color:var(--terracotta-text); } .acc.gemuese .rot b{ color:var(--leaf-text); }
.acc.fett .rot b{ color:var(--gold-text); } .acc.kh .rot b{ color:var(--slate); }
.searchbar{
display:flex; align-items:center; gap:8px; background:var(--surface); border:1px solid var(--line);
border-radius:10px; padding:10px 12px; margin-bottom:14px;
}
.searchbar input{ border:none; outline:none; font-size:0.9rem; flex:1; background:transparent; color:var(--ink); }
.searchbar .clear{ border:none; background:var(--bg); color:var(--ink-soft); border-radius:50%; width:22px; height:22px; font-size:0.75rem; display:none; }
.unitnote{ font-size:0.74rem; color:var(--ink-soft); background:var(--green-soft); border-radius:8px; padding:9px 12px; margin-bottom:14px; }
.recipe{ background:var(--sage-soft); border:1px solid var(--sage); border-radius:8px; padding:10px 12px; font-size:0.8rem; line-height:1.5; margin-top:8px; }
.recipe b{ color:var(--sage); }
.rules{ display:flex; flex-direction:column; gap:8px; border-top:1px solid var(--line); padding-top:14px; margin-top:16px; }
.rule{ display:flex; gap:8px; font-size:0.82rem; line-height:1.4; }
.rule::before{ content:"—"; color:var(--terracotta-text); flex:none; }
/* ---------- settings ---------- */
.setgroup{ border:1px solid var(--line); border-radius:10px; padding:14px 16px; margin-bottom:12px; }
.setgroup h3{ font-size:0.72rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--ink-soft); margin:0 0 10px; font-weight:700; }
.setrow{ display:flex; align-items:center; justify-content:space-between; gap:10px; }
.setrow .label{ font-size:0.88rem; font-weight:600; }
.setnote{ font-size:0.72rem; color:var(--ink-soft); margin-top:8px; line-height:1.4; }
.setnote.ok{ color:var(--pick); }
.setnote.warn{ color:var(--caution); }
.setgroup.disabled{ opacity:0.45; }
/* ---------- builder ---------- */
.builder-row{ margin-bottom:16px; }
.builder-row .rowhead{ display:flex; align-items:baseline; justify-content:space-between; margin-bottom:8px; }
.builder-row label{ font-size:0.7rem; font-weight:700; text-transform:uppercase; letter-spacing:0.05em; color:var(--ink-soft); }
.builder-row .rcount{ font-size:0.68rem; color:var(--ink-soft); }
.chip-grid{ display:flex; flex-wrap:wrap; gap:7px; }
.chip{
border:1px solid var(--line); background:var(--surface); color:var(--ink);
padding:8px 13px; border-radius:999px; font-size:0.82rem; font-weight:600;
display:flex; align-items:center; gap:5px; -webkit-user-select:none; user-select:none;
}
.chip .dotmark{ margin-right:0; }
.builder-row.protein .chip.on{ background:var(--green); border-color:var(--green); color:#fff; }
.builder-row.obstgem .chip.on{ background:var(--terracotta); border-color:var(--terracotta); color:#fff; }
.builder-row.fett .chip.on{ background:var(--gold); border-color:var(--gold); color:#111; }
.builder-row.kh .chip.on{ background:var(--slate); border-color:var(--slate); color:#fff; }
.chip.on .dotmark.pick, .chip.on .dotmark.caution{ filter:brightness(1.4) saturate(1.4); }
.builder-result{ margin-top:6px; padding:14px 16px; border-radius:10px; border:1px solid var(--line); font-size:0.85rem; line-height:1.5; }
.builder-result .bh{ font-weight:900; font-size:0.95rem; text-transform:uppercase; letter-spacing:0.01em; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
.builder-result.neutral{ background:var(--surface); }
.builder-result.solid{ background:var(--green-soft); }
.builder-result.solid .bh{ color:var(--green); }
.builder-result.pick{ background:var(--green-soft); }
.builder-result.pick .bh{ color:var(--green); }
.builder-result.caution{ background:#fdf1ec; }
.builder-result.caution .bh{ color:var(--terracotta-text); }
.builder-result ul{ margin:6px 0 0; padding-left:18px; }
.builder-result li{ margin-bottom:2px; }
</style>
<div class="topbar">
<button class="backbtn" id="backBtn" onclick="goHome()"></button>
<div class="tb-title">
<span class="eyebrow" id="topEyebrow">Body Comeback</span>
<h1 id="topTitle">Pocket Pascal</h1>
</div>
<div class="tb-actions">
<div class="langpill">
<button id="pillDe" onclick="setLang('de')">DE</button>
<button id="pillEn" onclick="setLang('en')">EN</button>
</div>
<button class="gearbtn" onclick="showView('settings')">⚙️</button>
</div>
</div>
<main>
<!-- HOME -->
<section class="view active" id="view-home">
<div class="homegrid" id="homegrid"></div>
</section>
<!-- TELLER -->
<section class="view" id="view-teller">
<div class="segctl">
<button id="segFull" class="active" onclick="setMeal('full')"></button>
<button id="segHalf" onclick="setMeal('half')"></button>
</div>
<div class="plate-wrap">
<div class="plate" id="plateFull" style="background:conic-gradient(var(--green) 0deg 120deg, var(--terracotta) 120deg 240deg, var(--gold) 240deg 300deg, var(--slate) 300deg 360deg);"></div>
<div class="plate" id="plateHalf" style="display:none; background:conic-gradient(var(--green) 0deg 200deg, var(--terracotta) 200deg 360deg);"></div>
<div class="legend" id="legendFull"></div>
<div class="legend" id="legendHalf" style="display:none;"></div>
</div>
<div id="stepsList"></div>
<div class="rules" id="tellerRules"></div>
</section>
<!-- VORRATSKAMMER -->
<section class="view" id="view-vorrat">
<div class="searchbar">
<span>🔍</span>
<input id="searchInput" oninput="onSearch()">
<button class="clear" id="clearBtn" onclick="clearSearch()"></button>
</div>
<div class="unitnote" id="unitnote"></div>
<div id="catList"></div>
</section>
<!-- BUILDER -->
<section class="view" id="view-builder">
<div class="segctl">
<button id="bSegFull" class="active" onclick="setBuilderMeal('full')"></button>
<button id="bSegHalf" onclick="setBuilderMeal('half')"></button>
</div>
<div id="builderPickers"></div>
<div class="builder-result neutral" id="builderResult"></div>
</section>
<!-- SETTINGS -->
<section class="view" id="view-settings">
<div class="setgroup">
<h3 id="setLangLabel">Sprache</h3>
<div class="setrow">
<span class="label">Deutsch / English</span>
<div class="langpill">
<button id="pillDe2" onclick="setLang('de')">DE</button>
<button id="pillEn2" onclick="setLang('en')">EN</button>
</div>
</div>
<div class="setnote" id="persistNote"></div>
</div>
<div class="setgroup disabled">
<h3 id="setMoreLabel">Weitere Einstellungen</h3>
<div class="setnote" id="setMoreNote"></div>
</div>
</section>
</main>
<script>
// ---------------------------------------------------------------------------
// i18n dictionary — all static UI strings
// ---------------------------------------------------------------------------
const UI = {
de: {
body:'Body Comeback', appTitle:'Pocket Pascal',
tileTellerT:'Dein Teller', tileTellerD:'Baukasten & Reihenfolge',
tileVorratT:'Vorratskammer', tileVorratD:'Einkauf & Kühlschrank',
tileSettingsT:'Einstellungen', tileSettingsD:'Sprache & mehr',
tileBuilderT:'Teller-Builder', tileBuilderD:'Kombi checken',
tileSugarT:'Zucker-Guide', tileSugarD:'Bald verfügbar',
tileCheckT:'Checkliste', tileCheckD:'Bald verfügbar',
card1:'Karte 1', card2:'Karte 2', cardSettings:'Einstellungen', cardBuilder:'Teller-Builder',
pickProtein:'Protein', pickObstGem:'Obst / Gemüse', pickFett:'Fett', pickKh:'Kohlenhydrate',
pickPlaceholder:'— auswählen —',
bResultMissingH:'Noch unvollständig',
bResultMissingList:'Fehlt noch:',
bResultSolidH:'Solider Teller',
bResultSolidHSnack:'Solider Snack',
bResultPickH:'Guter Teller!',
bResultPickHSnack:'Guter Snack!',
bResultPickList:'Besonders gut gewählt:',
bResultCautionH:'Mit Hinweis',
bResultCautionList:'Bewusst genießen:',
bResultCautionNote:'Kein Fehler — nur ein Punkt, den du im Blick behalten kannst.',
segFull:'Ganze Mahlzeit', segHalf:'Halbe Mahlzeit',
legFullP:'⅓ Protein', legFullVO:'⅓ Gemüse / Obst', legFullFK:'⅓ Fett + Kohlenhydrate',
legHalfP:'Protein', legHalfVO:'Gemüse / Obst',
rule1:'Jede Mahlzeit beruht auf Protein — egal ob süß, herzhaft, Snack oder Hauptmahlzeit.',
rule2:'Jede Mahlzeit enthält eine Portion Gemüse oder Obst.',
rule3:'4 Mahlzeiten am Tag, ca. alle 34 Std.',
rule4:'2 L Wasser täglich.',
searchPlaceholder:'Lebensmittel suchen…',
unitnote:'Werte je 100 g, sofern nicht anders angegeben.',
pickLabel:'besonders empfehlenswert', cautionLabel:'bewusst genießen',
noResults:'Keine Treffer.',
thumbDefault:'Daumenregel',
setLangLabel:'Sprache', setMoreLabel:'Weitere Einstellungen',
setMoreNote:'Mahlzeiten/Tag, Einkaufsrhythmus u.a. folgen in Phase 2.',
persistOk:'Spracheinstellung wird auf diesem Gerät gespeichert.',
persistWarn:'Spracheinstellung wird in dieser Ansicht nicht gespeichert — nach Neuladen wieder Deutsch.'
},
en: {
body:'Body Comeback', appTitle:'Pocket Pascal',
tileTellerT:'Your Plate', tileTellerD:'Building blocks & sequence',
tileVorratT:'The Pantry', tileVorratD:'Shopping & fridge',
tileSettingsT:'Settings', tileSettingsD:'Language & more',
tileBuilderT:'Plate Builder', tileBuilderD:'Check a combo',
tileSugarT:'Sugar Guide', tileSugarD:'Coming soon',
tileCheckT:'Checklist', tileCheckD:'Coming soon',
card1:'Card 1', card2:'Card 2', cardSettings:'Settings', cardBuilder:'Plate Builder',
pickProtein:'Protein', pickObstGem:'Veg / Fruit', pickFett:'Fat', pickKh:'Carbohydrate',
pickPlaceholder:'— choose —',
bResultMissingH:'Still incomplete',
bResultMissingList:'Still missing:',
bResultSolidH:'Solid plate',
bResultSolidHSnack:'Solid snack',
bResultPickH:'Good plate!',
bResultPickHSnack:'Good snack!',
bResultPickList:'Especially well chosen:',
bResultCautionH:'With a note',
bResultCautionList:'Enjoy mindfully:',
bResultCautionNote:'Not a mistake — just something to keep an eye on.',
segFull:'Full meal', segHalf:'Half meal',
legFullP:'⅓ Protein', legFullVO:'⅓ Veg / Fruit', legFullFK:'⅓ Fat + Carbs',
legHalfP:'Protein', legHalfVO:'Veg / Fruit',
rule1:'Every meal is built on protein — sweet, savoury, snack, or main.',
rule2:'Every meal includes a portion of vegetables or fruit.',
rule3:'4 meals a day, roughly every 34 hours.',
rule4:'2 L water daily.',
searchPlaceholder:'Search foods…',
unitnote:'Values per 100 g unless noted otherwise.',
pickLabel:'especially recommended', cautionLabel:'enjoy mindfully',
noResults:'No matches.',
thumbDefault:'Rule of thumb',
setLangLabel:'Language', setMoreLabel:'More settings',
setMoreNote:'Meals/day, shopping rhythm and more arrive in Phase 2.',
persistOk:'Language choice is saved on this device.',
persistWarn:'Language choice isnt saved in this view — reloads back to German.'
}
};
// ---------------------------------------------------------------------------
// data model — structured records, name/text as {de,en}
// ---------------------------------------------------------------------------
const STEPS = [
{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).'}}
];
// f: 'pick' | 'caution' | undefined
const CATS = [
{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'}
]}
];
// ---------------------------------------------------------------------------
// state
// ---------------------------------------------------------------------------
let persistWorks = true;
function loadLang(){
try{
const v = localStorage.getItem('pp_lang');
return v === 'en' ? 'en' : 'de';
}catch(e){ persistWorks = false; return 'de'; }
}
function saveLang(l){
try{ localStorage.setItem('pp_lang', l); }catch(e){ persistWorks = false; }
}
const state = { lang: loadLang() };
const builderState = { meal:'full', sel:{ protein:[], obstgem:[], fett:[], kh:[] } };
function t(key){ return UI[state.lang][key]; }
function tr(obj){ return obj[state.lang]; }
// ---------------------------------------------------------------------------
// render helpers
// ---------------------------------------------------------------------------
function dot(f){
if(f==='pick') return `<span class="dotmark pick">●</span>`;
if(f==='caution') return `<span class="dotmark caution">●</span>`;
return '';
}
function rowsHtml(items){
return items.map(it=>`<tr><td>${tr(it.name)}</td><td class="${it.approx?'approx':''}">${dot(it.f)}${tr(it.v)}</td></tr>`).join('');
}
// ---------------------------------------------------------------------------
// builder helpers
// ---------------------------------------------------------------------------
function catItems(id){
const c = CATS.find(x=>x.id===id);
return c.sub ? c.items.concat(c.sub.items) : c.items;
}
function obstGemItems(){
return catItems('obst').concat(catItems('gemuese'));
}
function findItem(slot, id){
const list = slot==='protein' ? catItems('protein')
: slot==='obstgem' ? obstGemItems()
: slot==='fett' ? catItems('fett')
: catItems('kh');
return list.find(it=>it.id===id);
}
function renderHome(){
document.getElementById('homegrid').innerHTML = `
<button class="tile teller" onclick="showView('teller')">
<span class="ic">🍽️</span>
<div><div class="t">${t('tileTellerT')}</div><div class="d">${t('tileTellerD')}</div></div>
</button>
<button class="tile vorrat" onclick="showView('vorrat')">
<span class="ic">🧺</span>
<div><div class="t">${t('tileVorratT')}</div><div class="d">${t('tileVorratD')}</div></div>
</button>
<button class="tile builder" onclick="showView('builder')">
<span class="ic">🧩</span>
<div><div class="t">${t('tileBuilderT')}</div><div class="d">${t('tileBuilderD')}</div></div>
</button>
<button class="tile settings" onclick="showView('settings')">
<span class="ic">⚙️</span>
<div><div class="t">${t('tileSettingsT')}</div><div class="d">${t('tileSettingsD')}</div></div>
</button>
<button class="tile soon" disabled>
<span class="ic">🍬</span>
<div><div class="t">${t('tileSugarT')}</div><div class="d">${t('tileSugarD')}</div></div>
</button>
<button class="tile soon" disabled>
<span class="ic">✅</span>
<div><div class="t">${t('tileCheckT')}</div><div class="d">${t('tileCheckD')}</div></div>
</button>
`;
}
function renderSteps(){
document.getElementById('stepsList').innerHTML = STEPS.map(s=>`
<div class="acc" id="step-${s.n}">
<button class="acc-head" onclick="toggleAcc('step-${s.n}')">
<span class="num ${s.cls}">${s.n}</span>
<span class="title">${tr(s.title)}</span>
${tr(s.qty) ? `<span class="qty">${tr(s.qty)}</span>` : ''}
<span class="chev"></span>
</button>
<div class="acc-body"><p>${tr(s.text)}</p></div>
</div>
`).join('');
}
function renderTellerStatic(){
document.getElementById('segFull').textContent = t('segFull');
document.getElementById('segHalf').textContent = t('segHalf');
document.getElementById('legendFull').innerHTML = `
<span><i class="dot g"></i>${t('legFullP')}</span>
<span><i class="dot t"></i>${t('legFullVO')}</span>
<span><i class="dot o"></i><i class="dot s"></i>${t('legFullFK')}</span>
`;
document.getElementById('legendHalf').innerHTML = `
<span><i class="dot g"></i>${t('legHalfP')}</span>
<span><i class="dot t"></i>${t('legHalfVO')}</span>
`;
document.getElementById('tellerRules').innerHTML = ['rule1','rule2','rule3','rule4']
.map(k=>`<div class="rule">${t(k)}</div>`).join('');
}
function renderCats(filter){
const f = (filter||'').trim().toLowerCase();
const html = CATS.map(c=>{
let items = c.items;
let subitems = c.sub ? c.sub.items : null;
let matchCount = items.length + (subitems?subitems.length:0);
let forceOpen = false;
if(f){
items = c.items.filter(it=>tr(it.name).toLowerCase().includes(f));
subitems = subitems ? subitems.filter(it=>tr(it.name).toLowerCase().includes(f)) : null;
matchCount = items.length + (subitems?subitems.length:0);
forceOpen = matchCount>0;
if(matchCount===0) return '';
}
return `
<div class="acc ${c.cls} ${forceOpen?'open':''}" id="cat-${c.id}">
<button class="acc-head cat-head" onclick="toggleAcc('cat-${c.id}')">
<span class="ic">${c.ic}</span>
<span class="title">${tr(c.title)}</span>
<span class="count">${matchCount}</span>
<span class="chev"></span>
</button>
<div class="acc-body">
<table>${rowsHtml(items)}</table>
${subitems && subitems.length ? `<p class="subhead">${tr(c.sub.title)}</p><table>${rowsHtml(subitems)}</table>` : ''}
${c.recipe ? `<div class="recipe"><b>${tr(c.recipe.title)}</b><br>${tr(c.recipe.text)}</div>` : ''}
${c.note ? `<p class="subhead" style="text-transform:none;font-weight:400;">${tr(c.note)}</p>` : ''}
<div class="rot"><b>${c.thumbLabel ? tr(c.thumbLabel) : t('thumbDefault')}</b><br>${tr(c.thumb)}</div>
</div>
</div>`;
}).join('');
document.getElementById('catList').innerHTML = html || `<p style="color:var(--ink-soft); font-size:0.85rem;">${t('noResults')}</p>`;
}
function renderVorratStatic(){
document.getElementById('searchInput').placeholder = t('searchPlaceholder');
document.getElementById('unitnote').innerHTML =
`${t('unitnote')} <span class="dotmark pick">●</span> ${t('pickLabel')} · <span class="dotmark caution">●</span> ${t('cautionLabel')}`;
}
function renderBuilder(){
document.getElementById('bSegFull').textContent = t('segFull');
document.getElementById('bSegHalf').textContent = t('segHalf');
const slots = builderState.meal==='full'
? [['protein','pickProtein'],['obstgem','pickObstGem'],['fett','pickFett'],['kh','pickKh']]
: [['protein','pickProtein'],['obstgem','pickObstGem']];
document.getElementById('builderPickers').innerHTML = slots.map(([slot,labelKey])=>{
const list = slot==='protein' ? catItems('protein')
: slot==='obstgem' ? obstGemItems()
: slot==='fett' ? catItems('fett')
: catItems('kh');
const chips = list.map(it=>{
const on = builderState.sel[slot].includes(it.id);
return `<button class="chip ${on?'on':''}" onclick="toggleBuilderSel('${slot}','${it.id}')">${dot(it.f)}${tr(it.name)}</button>`;
}).join('');
const count = builderState.sel[slot].length;
return `
<div class="builder-row ${slot}">
<div class="rowhead">
<label>${t(labelKey)}</label>
${count ? `<span class="rcount">${count}×</span>` : ''}
</div>
<div class="chip-grid">${chips}</div>
</div>`;
}).join('');
evaluateBuilder();
}
function toggleBuilderSel(slot, id){
const arr = builderState.sel[slot];
const idx = arr.indexOf(id);
if(idx===-1) arr.push(id); else arr.splice(idx,1);
renderBuilder();
}
function setBuilderMeal(which){
builderState.meal = which;
document.getElementById('bSegFull').classList.toggle('active', which==='full');
document.getElementById('bSegHalf').classList.toggle('active', which==='half');
renderBuilder();
}
function evaluateBuilder(){
const required = builderState.meal==='full' ? ['protein','obstgem','fett','kh'] : ['protein','obstgem'];
const missing = required.filter(k=>builderState.sel[k].length===0);
const box = document.getElementById('builderResult');
const labelKeys = {protein:'pickProtein', obstgem:'pickObstGem', fett:'pickFett', kh:'pickKh'};
if(missing.length){
box.className = 'builder-result neutral';
box.innerHTML = `<div class="bh">${t('bResultMissingH')}</div>
<div>${t('bResultMissingList')} ${missing.map(k=>t(labelKeys[k])).join(', ')}</div>`;
return;
}
const items = [];
required.forEach(k=>builderState.sel[k].forEach(id=>items.push({slot:k, item:findItem(k, id)})));
const cautions = items.filter(x=>x.item.f==='caution');
const picks = items.filter(x=>x.item.f==='pick');
const isSnack = builderState.meal==='half';
if(cautions.length){
box.className = 'builder-result caution';
box.innerHTML = `<div class="bh">🧡 ${t('bResultCautionH')}</div>
<div>${t('bResultCautionList')}</div>
<ul>${cautions.map(x=>`<li>${tr(x.item.name)}</li>`).join('')}</ul>
<div style="margin-top:6px;">${t('bResultCautionNote')}</div>`;
} else if(picks.length){
box.className = 'builder-result pick';
box.innerHTML = `<div class="bh">✅ ${isSnack ? t('bResultPickHSnack') : t('bResultPickH')}</div>
<div>${t('bResultPickList')}</div>
<ul>${picks.map(x=>`<li>${tr(x.item.name)}</li>`).join('')}</ul>`;
} else {
box.className = 'builder-result solid';
box.innerHTML = `<div class="bh">👍 ${isSnack ? t('bResultSolidHSnack') : t('bResultSolidH')}</div>`;
}
}
function renderSettings(){
document.getElementById('setLangLabel').textContent = t('setLangLabel');
document.getElementById('setMoreLabel').textContent = t('setMoreLabel');
document.getElementById('setMoreNote').textContent = t('setMoreNote');
const note = document.getElementById('persistNote');
note.textContent = persistWorks ? t('persistOk') : t('persistWarn');
note.className = 'setnote ' + (persistWorks ? 'ok' : 'warn');
}
function renderLangPills(){
const de = state.lang==='de';
['pillDe','pillDe2'].forEach(id=>document.getElementById(id).classList.toggle('active', de));
['pillEn','pillEn2'].forEach(id=>document.getElementById(id).classList.toggle('active', !de));
}
function renderAll(){
renderLangPills();
renderHome();
renderSteps();
renderTellerStatic();
renderVorratStatic();
renderCats(document.getElementById('searchInput').value);
renderBuilder();
renderSettings();
refreshTopTitle();
}
function setLang(l){
if(l===state.lang) return;
state.lang = l;
saveLang(l);
renderAll();
}
// ---------------------------------------------------------------------------
// navigation
// ---------------------------------------------------------------------------
function toggleAcc(id){
document.getElementById(id).classList.toggle('open');
}
function setMeal(which){
const isFull = which==='full';
document.getElementById('segFull').classList.toggle('active', isFull);
document.getElementById('segHalf').classList.toggle('active', !isFull);
document.getElementById('plateFull').style.display = isFull?'block':'none';
document.getElementById('plateHalf').style.display = isFull?'none':'block';
document.getElementById('legendFull').style.display = isFull?'flex':'none';
document.getElementById('legendHalf').style.display = isFull?'none':'flex';
}
let currentView = 'home';
function refreshTopTitle(){
const back = document.getElementById('backBtn');
const eyebrow = document.getElementById('topEyebrow');
const title = document.getElementById('topTitle');
if(currentView==='home'){
back.classList.remove('show');
eyebrow.textContent = t('body');
title.textContent = t('appTitle');
} else {
back.classList.add('show');
if(currentView==='teller'){ eyebrow.textContent = t('card1'); title.textContent = t('tileTellerT'); }
else if(currentView==='vorrat'){ eyebrow.textContent = t('card2'); title.textContent = t('tileVorratT'); }
else if(currentView==='builder'){ eyebrow.textContent = t('appTitle'); title.textContent = t('cardBuilder'); }
else if(currentView==='settings'){ eyebrow.textContent = t('appTitle'); title.textContent = t('cardSettings'); }
}
}
function showView(name){
currentView = name;
document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));
document.getElementById('view-'+name).classList.add('active');
refreshTopTitle();
window.scrollTo(0,0);
history.pushState({view:name}, '', '#'+name);
}
function goHome(){ showView('home'); }
function onSearch(){
const val = document.getElementById('searchInput').value;
document.getElementById('clearBtn').style.display = val ? 'block' : 'none';
renderCats(val);
}
function clearSearch(){
document.getElementById('searchInput').value='';
document.getElementById('clearBtn').style.display='none';
renderCats('');
}
window.addEventListener('popstate', (e)=>{
const v = (e.state && e.state.view) || 'home';
currentView = v;
document.querySelectorAll('.view').forEach(el=>el.classList.remove('active'));
document.getElementById('view-'+v).classList.add('active');
refreshTopTitle();
});
renderAll();
</script>

358
pnpm-lock.yaml generated
View File

@@ -38,6 +38,12 @@ importers:
react-dom:
specifier: ^19.2.8
version: 19.2.8(react@19.2.8)
react-router-dom:
specifier: ^7.18.2
version: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
recharts:
specifier: ^3.10.1
version: 3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1)
shadcn:
specifier: ^4.16.1
version: 4.16.1(typescript@6.0.3)
@@ -63,6 +69,9 @@ importers:
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.4(@types/react@19.2.18)
'@types/react-router-dom':
specifier: ^5.3.3
version: 5.3.3
'@vitejs/plugin-react':
specifier: ^6.0.4
version: 6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))
@@ -1016,6 +1025,17 @@ packages:
cpu: [x64]
os: [win32]
'@reduxjs/toolkit@2.12.0':
resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/binding-android-arm64@1.2.2':
resolution: {integrity: sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1284,6 +1304,12 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
@@ -1390,6 +1416,33 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -1399,6 +1452,9 @@ packages:
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
'@types/history@4.7.11':
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
@@ -1416,6 +1472,12 @@ packages:
peerDependencies:
'@types/react': ^19.2.0
'@types/react-router-dom@5.3.3':
resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
'@types/react-router@5.1.20':
resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
@@ -1431,6 +1493,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/validate-npm-package-name@4.0.2':
resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==}
@@ -1722,6 +1787,10 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
core-js-compat@3.49.0:
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
@@ -1754,6 +1823,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
engines: {node: '>= 0.4'}
@@ -1787,6 +1900,9 @@ packages:
supports-color:
optional: true
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
@@ -1926,6 +2042,9 @@ packages:
resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==}
engines: {node: '>= 0.4'}
es-toolkit@1.50.0:
resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==}
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
@@ -1958,6 +2077,9 @@ packages:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
@@ -2220,6 +2342,9 @@ packages:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
immer@11.1.15:
resolution: {integrity: sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -2234,6 +2359,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
ip-address@10.4.0:
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
engines: {node: '>= 12'}
@@ -3019,6 +3148,38 @@ packages:
peerDependencies:
react: ^19.2.8
react-is@19.2.8:
resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==}
react-redux@9.3.0:
resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-router-dom@7.18.2:
resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.18.2:
resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.8:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
@@ -3031,6 +3192,22 @@ packages:
resolution: {integrity: sha512-T98lym7kH+pnZmRaD8yDRdaNqyUbwnbEBx0MuchrzMFOEMray4AO3ZJoTUZ5r78Ao78X/OhzW0DL8GB85w/I2w==}
engines: {node: '>= 4'}
recharts@3.10.1:
resolution: {integrity: sha512-QXFrvt6IVcw7eeZCoyXTwkIJAX3Dv1nyVhMicXJ47GsGDDpcN8z6o644DibE9XjpBTThtsomLKnTV6lc+cVFUA==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -3159,6 +3336,9 @@ packages:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -3516,6 +3696,9 @@ packages:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite-plugin-pwa@1.3.0:
resolution: {integrity: sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==}
engines: {node: '>=16.0.0'}
@@ -4658,6 +4841,18 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.77.0':
optional: true
'@reduxjs/toolkit@2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.15
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.2.0
optionalDependencies:
react: 19.2.8
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
'@rolldown/binding-android-arm64@1.2.2':
optional: true
@@ -4824,6 +5019,10 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -4918,6 +5117,30 @@ snapshots:
dependencies:
'@types/node': 24.13.3
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/estree@1.0.9': {}
'@types/express-serve-static-core@5.1.3':
@@ -4933,6 +5156,8 @@ snapshots:
'@types/express-serve-static-core': 5.1.3
'@types/serve-static': 2.2.0
'@types/history@4.7.11': {}
'@types/http-errors@2.0.5': {}
'@types/node@24.13.3':
@@ -4947,6 +5172,17 @@ snapshots:
dependencies:
'@types/react': 19.2.18
'@types/react-router-dom@5.3.3':
dependencies:
'@types/history': 4.7.11
'@types/react': 19.2.18
'@types/react-router': 5.1.20
'@types/react-router@5.1.20':
dependencies:
'@types/history': 4.7.11
'@types/react': 19.2.18
'@types/react@19.2.18':
dependencies:
csstype: 3.2.3
@@ -4964,6 +5200,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/use-sync-external-store@0.0.6': {}
'@types/validate-npm-package-name@4.0.2': {}
'@vitejs/plugin-react@6.0.5(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))':
@@ -5258,6 +5496,8 @@ snapshots:
cookie@0.7.2: {}
cookie@1.1.1: {}
core-js-compat@3.49.0:
dependencies:
browserslist: 4.28.7
@@ -5288,6 +5528,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-view-buffer@1.0.2:
dependencies:
call-bound: 1.0.4
@@ -5318,6 +5596,8 @@ snapshots:
dependencies:
ms: 2.1.3
decimal.js-light@2.5.1: {}
decompress-response@6.0.0:
dependencies:
mimic-response: 3.1.0
@@ -5493,6 +5773,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es-toolkit@1.50.0: {}
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
@@ -5536,6 +5818,8 @@ snapshots:
etag@1.8.1: {}
eventemitter3@5.0.4: {}
eventsource-parser@3.1.0: {}
eventsource@3.0.7:
@@ -5879,6 +6163,8 @@ snapshots:
ignore@5.3.2: {}
immer@11.1.15: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -5894,6 +6180,8 @@ snapshots:
hasown: 2.0.4
side-channel: 1.1.1
internmap@2.0.3: {}
ip-address@10.4.0: {}
ipaddr.js@1.9.1: {}
@@ -6557,6 +6845,31 @@ snapshots:
react: 19.2.8
scheduler: 0.27.0
react-is@19.2.8: {}
react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.8
use-sync-external-store: 1.6.0(react@19.2.8)
optionalDependencies:
'@types/react': 19.2.18
redux: 5.0.1
react-router-dom@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-router: 7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react-router@7.18.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
cookie: 1.1.1
react: 19.2.8
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.8(react@19.2.8)
react@19.2.8: {}
readable-stream@3.6.2:
@@ -6573,6 +6886,32 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
recharts@3.10.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react-is@19.2.8)(react@19.2.8)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1))(react@19.2.8)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.50.0
eventemitter3: 5.0.4
immer: 11.1.15
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
react-is: 19.2.8
react-redux: 9.3.0(@types/react@19.2.18)(react@19.2.8)(redux@5.0.1)
reselect: 5.2.0
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.8)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.9
@@ -6791,6 +7130,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-cookie-parser@2.7.2: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -7204,6 +7545,23 @@ snapshots:
vary@1.1.2: {}
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-plugin-pwa@1.3.0(vite@8.2.0(@types/node@24.13.3)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(tsx@4.23.5))(workbox-build@7.4.1)(workbox-window@7.4.1):
dependencies:
debug: 4.4.3

View File

@@ -30,5 +30,3 @@ if (fs.existsSync(clientDist)) {
app.listen(PORT, () => {
console.log(`Pocket Pascal server listening on http://localhost:${PORT}`)
})
// deploy test Tue Aug 4 13:19:10 UTC 2026
// deploy test 1785849660