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

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

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

View File

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