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

View File

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

View File

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

View File

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

View File

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