Files
Steffi Müller a482bf5b09 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>
2026-08-04 12:22:32 +02:00

113 lines
3.6 KiB
TypeScript

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";