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>
This commit is contained in:
Steffi Müller
2026-08-04 15:52:24 +02:00
parent a482bf5b09
commit a0d415b08f
43 changed files with 3400 additions and 1358 deletions

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

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

@@ -1,15 +1,20 @@
import { Outlet, useLocation, useNavigate } from "react-router-dom"
import { BookOpen, Home, Settings2, ShoppingBasket } from "lucide-react"
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: "home", path: "/", icon: Home, labelKey: "home" as const },
{ key: "pantry", path: "/pantry", icon: ShoppingBasket, labelKey: "pantry" as const },
{ key: "knowledge", path: "/knowledge", icon: BookOpen, labelKey: "knowledge" as const },
{ key: "settings", path: "/settings", icon: Settings2, labelKey: "settings" as const },
{ 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() {

View File

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

@@ -70,9 +70,7 @@ export function Accordion({
"active:brightness-[0.98]",
)}
>
{icon ? (
<span className="shrink-0 text-xl leading-none">{icon}</span>
) : null}
{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>

View File

@@ -17,6 +17,7 @@ export interface BottomNavProps {
/**
* 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 (
@@ -49,7 +50,7 @@ export function BottomNav({ items, className }: BottomNavProps) {
className={cx(
"text-xl leading-none [&_svg]:h-5 [&_svg]:w-5",
item.active
? "text-[var(--color-obstgem-dk)]"
? "text-[var(--ui-accent)]"
: "text-[var(--ink-soft)]",
)}
>
@@ -59,14 +60,9 @@ export function BottomNav({ items, className }: BottomNavProps) {
className={cx(
"type-label normal-case tracking-wide",
item.active
? "bg-clip-text text-transparent"
? "text-[var(--ui-accent)]"
: "text-[var(--ink-soft)]",
)}
style={
item.active
? { backgroundImage: "var(--gradient-accent)" }
: undefined
}
>
{item.label}
</span>

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>
)
}