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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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