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:
88
handover/component-library/components/BottomNav.tsx
Normal file
88
handover/component-library/components/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
handover/component-library/components/Button.tsx
Normal file
112
handover/component-library/components/Button.tsx
Normal 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";
|
||||
62
handover/component-library/components/GlassCard.tsx
Normal file
62
handover/component-library/components/GlassCard.tsx
Normal 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";
|
||||
121
handover/component-library/components/PlateChart.tsx
Normal file
121
handover/component-library/components/PlateChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user