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>
113 lines
3.6 KiB
TypeScript
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";
|