Move UI to BoardUI with slate + salmon theme; add PDF plan export
- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button, SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's color aliases onto BoardUI tokens and swap lucide for Remix icons - Retint BoardUI's neutral ramp to cool slate with a salmon accent; drop the dotted background; raise muted text contrast - Simplify the landing page and remove the overlap explorer - Planner: explicit "I have this" toggle on grocery lines, pantry panel above the menu with the staples list spelled out, sticky aisle header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded) - Recipes: replace the family cabbage soup with Cade's Cabbage Soup (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup - Remove unused BoardUI components, dependencies and exports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b02c7a024b
commit
df5dd3a70e
56 changed files with 4749 additions and 788 deletions
217
components/base/buttons/button.tsx
Normal file
217
components/base/buttons/button.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import type {
|
||||
AnchorHTMLAttributes,
|
||||
ButtonHTMLAttributes,
|
||||
ComponentType,
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from "react";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { directionalIconClass } from "@/utils/directional-icon";
|
||||
|
||||
/**
|
||||
* Figma source: Board UI → Buttons (node 3656:13819).
|
||||
*
|
||||
* Variant matrix from Figma:
|
||||
* Type = Primary | Secondary | Ghost | Danger
|
||||
* Size = Medium | Small | Xs
|
||||
* State = Default | Hover | Active | Disabled (CSS pseudo)
|
||||
* OnlyIcon = false | true
|
||||
*
|
||||
* Sizing (1:1 with Figma):
|
||||
*
|
||||
* Medium Small Xs
|
||||
* container h=36, p=8, r=10 h=32, px=8 py=6, r=8 h=24, px=8, r=4
|
||||
* gap 2px 2px 1.33px→1
|
||||
* icon 20×20 18×18 14×14
|
||||
* label wrapper px=4 px=2 px=2
|
||||
* text style Body 1/Medium Body 1/Medium Caption 1/Semibold
|
||||
* icon-only square 36×36 (content-derived) 32×32 (forced size) 24×24 (forced size)
|
||||
*
|
||||
* `xs` is the smallest tier — first needed for the calendar template's
|
||||
* event-details modal ("Join" / edit-icon buttons, node 3920:10954), which
|
||||
* scales every dimension down by the same ~0.667 factor from Figma; the
|
||||
* table above rounds those to clean pixel values rather than reproducing
|
||||
* the fractional source numbers.
|
||||
*
|
||||
* Icons are rendered by the component itself via the `leadingIcon` /
|
||||
* `trailingIcon` props so the consumer can't pass the wrong size. Pass
|
||||
* a Remix Icon component reference (`RiAddLine`, not `<RiAddLine />`).
|
||||
*
|
||||
* For icon-only buttons:
|
||||
* <Button iconOnly leadingIcon={RiAddLine} aria-label="Add" />
|
||||
*
|
||||
* The HTML `type` prop is preserved; Figma's "Type" enum is renamed to
|
||||
* `variant` to avoid the clash.
|
||||
*/
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
type ButtonSize = "medium" | "small" | "xs";
|
||||
|
||||
type IconComponent = ComponentType<{
|
||||
className?: string;
|
||||
"aria-hidden"?: boolean | "true" | "false";
|
||||
}>;
|
||||
|
||||
export interface ButtonProps
|
||||
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
iconOnly?: boolean;
|
||||
leadingIcon?: IconComponent;
|
||||
trailingIcon?: IconComponent;
|
||||
children?: ReactNode;
|
||||
ref?: Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export interface ButtonLinkProps
|
||||
extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "children"> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
iconOnly?: boolean;
|
||||
leadingIcon?: IconComponent;
|
||||
trailingIcon?: IconComponent;
|
||||
children?: ReactNode;
|
||||
ref?: Ref<HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
const styles = sortCx({
|
||||
base: [
|
||||
"inline-flex items-center justify-center gap-0.5 whitespace-nowrap overflow-hidden",
|
||||
"font-sans select-none cursor-pointer",
|
||||
"button-press-motion",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-focus-ring",
|
||||
"disabled:cursor-not-allowed aria-disabled:cursor-not-allowed",
|
||||
].join(" "),
|
||||
|
||||
// Base shape per size (used when label is present OR medium icon-only).
|
||||
size: {
|
||||
medium: "h-9 rounded-2lg p-2 text-body-medium",
|
||||
small: "h-8 rounded-lg px-2 py-1.5 text-body-medium",
|
||||
xs: "h-6 rounded-sm px-2 text-caption-1-semibold",
|
||||
},
|
||||
|
||||
// Icon-only override:
|
||||
// Medium → keep p-2; w expands from content (8+20+8 = 36) → square.
|
||||
// Small → Figma forces 32×32 even though 8+18+8=34, so we hard-set size-8
|
||||
// and zero the padding; the inner flex centers the 18px icon.
|
||||
// Xs → forces 24×24, content-centered — used for the calendar
|
||||
// template's edit-icon buttons (timezone/participants/reminder).
|
||||
iconOnlySize: {
|
||||
medium: "", // base size already produces 36×36 with a 20px icon
|
||||
small: "size-8 p-0", // hard 32×32, content-centered
|
||||
xs: "size-6 p-0", // hard 24×24, content-centered
|
||||
},
|
||||
|
||||
icon: {
|
||||
medium: "size-5 shrink-0", // 20px
|
||||
small: "size-[18px] shrink-0", // 18px
|
||||
xs: "size-3.5 shrink-0", // 14px
|
||||
},
|
||||
|
||||
label: {
|
||||
medium: "inline-flex items-center justify-center px-1 shrink-0", // px=4
|
||||
small: "inline-flex items-center justify-center px-0.5 shrink-0", // px=2
|
||||
xs: "inline-flex items-center justify-center px-0.5 shrink-0", // px=2
|
||||
},
|
||||
|
||||
variant: {
|
||||
primary: [
|
||||
"bg-button-primary text-text-white shadow-xs",
|
||||
"disabled:text-button-primary-disabled-foreground disabled:shadow-none",
|
||||
"aria-disabled:text-button-primary-disabled-foreground aria-disabled:shadow-none",
|
||||
].join(" "),
|
||||
danger: [
|
||||
"bg-button-danger text-text-white shadow-xs",
|
||||
"disabled:text-foreground-disabled-danger disabled:shadow-none",
|
||||
"aria-disabled:text-foreground-disabled-danger aria-disabled:shadow-none",
|
||||
].join(" "),
|
||||
secondary: [
|
||||
"bg-background-primary-default text-text-primary",
|
||||
"border border-border-button-default shadow-xs",
|
||||
"hover:bg-background-primary-hover hover:border-border-button-hover",
|
||||
"active:bg-background-primary-active active:border-border-button-active",
|
||||
"disabled:bg-background-primary-disabled disabled:border-border-button-default disabled:text-text-tertiary disabled:shadow-none",
|
||||
"aria-disabled:bg-background-primary-disabled aria-disabled:border-border-button-default aria-disabled:text-text-tertiary aria-disabled:shadow-none",
|
||||
].join(" "),
|
||||
ghost: [
|
||||
"bg-button-ghost-background text-button-ghost-foreground",
|
||||
"hover:bg-button-ghost-hover active:bg-button-ghost-active",
|
||||
"disabled:bg-button-ghost-disabled disabled:text-button-ghost-disabled-foreground disabled:shadow-none",
|
||||
"aria-disabled:bg-button-ghost-disabled aria-disabled:text-button-ghost-disabled-foreground aria-disabled:shadow-none",
|
||||
].join(" "),
|
||||
},
|
||||
});
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
size = "medium",
|
||||
iconOnly = false,
|
||||
leadingIcon: Leading,
|
||||
trailingIcon: Trailing,
|
||||
children,
|
||||
className,
|
||||
type = "button",
|
||||
ref,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cx(
|
||||
styles.base,
|
||||
styles.size[size],
|
||||
styles.variant[variant],
|
||||
iconOnly && styles.iconOnlySize[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Leading ? <Leading className={cx(styles.icon[size], directionalIconClass(Leading))} aria-hidden /> : null}
|
||||
{!iconOnly && children !== undefined && children !== null && (
|
||||
<span className={styles.label[size]}>{children}</span>
|
||||
)}
|
||||
{!iconOnly && Trailing ? (
|
||||
<Trailing className={cx(styles.icon[size], directionalIconClass(Trailing))} aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anchor counterpart to Button for navigational actions. */
|
||||
export function ButtonLink({
|
||||
variant = "primary",
|
||||
size = "medium",
|
||||
iconOnly = false,
|
||||
leadingIcon: Leading,
|
||||
trailingIcon: Trailing,
|
||||
children,
|
||||
className,
|
||||
ref,
|
||||
...props
|
||||
}: ButtonLinkProps) {
|
||||
return (
|
||||
<a
|
||||
ref={ref}
|
||||
className={cx(
|
||||
styles.base,
|
||||
styles.size[size],
|
||||
styles.variant[variant],
|
||||
iconOnly && styles.iconOnlySize[size],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{Leading ? <Leading className={cx(styles.icon[size], directionalIconClass(Leading))} aria-hidden /> : null}
|
||||
{!iconOnly && children !== undefined && children !== null && (
|
||||
<span className={styles.label[size]}>{children}</span>
|
||||
)}
|
||||
{!iconOnly && Trailing ? (
|
||||
<Trailing className={cx(styles.icon[size], directionalIconClass(Trailing))} aria-hidden />
|
||||
) : null}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Style maps, exported for advanced composition and the dev Design Tuner. */
|
||||
export const buttonStyles = styles;
|
||||
Loading…
Add table
Add a link
Reference in a new issue