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:
Cayden Schwechten 2026-11-06 16:36:33 -06:00
parent b02c7a024b
commit df5dd3a70e
56 changed files with 4749 additions and 788 deletions

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

View file

@ -0,0 +1,70 @@
"use client";
import type { ReactNode, Ref } from "react";
import { RiInformationFill } from "@remixicon/react";
import type { LabelProps as AriaLabelProps } from "react-aria-components";
import { Label as AriaLabel } from "react-aria-components";
import { cx } from "@/utils/cx";
/**
* Field label. Wraps `react-aria-components`' Label so the parent `TextField`
* automatically wires `htmlFor` / `aria-labelledby`.
*
* Visuals come from Figma:
* - Inter Medium 14/20, color text/primary
* - 2px gap between label + required asterisk + info icon
* - asterisk is text/error-primary (red/500)
* - info icon is 16×16 in foreground/icon/quaternary
*
* Pass `isRequired` to show the asterisk. Pass `tooltip` to show the info
* icon (currently a static glyph — when the Tooltip component lands it'll
* wire up here).
*/
export interface LabelProps extends AriaLabelProps {
children: ReactNode;
isRequired?: boolean;
/** Reserved for invalid-aware styling (asterisk color, etc.) once we need it. */
isInvalid?: boolean;
/** Show the info icon next to the label. */
tooltip?: boolean | string;
ref?: Ref<HTMLLabelElement>;
}
export function Label({
isRequired = false,
isInvalid: _isInvalid,
tooltip,
className,
children,
...props
}: LabelProps) {
void _isInvalid;
return (
<AriaLabel
data-label="true"
{...props}
className={cx(
"flex cursor-default items-center gap-0.5",
"text-body-medium text-text-primary",
className,
)}
>
{children}
{isRequired && (
<span
aria-hidden="true"
className="text-body-medium text-text-error-primary"
>
*
</span>
)}
{tooltip && (
<RiInformationFill
className="size-4 shrink-0 text-foreground-icon-quaternary"
aria-hidden
/>
)}
</AriaLabel>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import type { ReactNode } from "react";
import { Meter as AriaMeter } from "react-aria-components";
import type { MeterProps as AriaMeterProps } from "react-aria-components";
import { Label } from "@/components/base/input/label";
import { ProgressTrack, type ProgressSize } from "@/components/base/progress/progress";
import { cx, sortCx } from "@/utils/cx";
/**
* Meter — a quantity within a known range: storage used, seats taken, a
* password's strength. Unlike ProgressBar it describes a level rather than
* a task moving towards done, and React Aria's Meter announces it that way
* (role="meter").
*
* <Meter label="Storage" value={72} valueLabel="7.2 of 10 GB" />
* <Meter label="Seats" value={19} maxValue={20} tone="warning" />
*
* It is drawn on ProgressBar's track: the same rail, fill, and eased
* translate when the value changes. `tone` colours the fill from semantic
* tokens, so a meter can turn amber or red as it fills up.
*/
export type MeterTone = "accent" | "success" | "warning" | "error";
const tones = sortCx({
accent: "text-accent-500",
success: "text-notification-success-foreground",
warning: "text-chart-8-active",
error: "text-notification-error-foreground",
});
export interface MeterProps extends Omit<AriaMeterProps, "children" | "className"> {
label?: ReactNode;
/** Show the formatted value at the end of the label row. Default: shown with a label. */
showValueLabel?: boolean;
/** Helper text under the bar. */
description?: ReactNode;
/** Colour of the fill. Default `accent`. */
tone?: MeterTone;
/** Track thickness: 4, 6 (default), or 8px. */
size?: ProgressSize;
className?: string;
}
export function Meter({
label,
showValueLabel,
description,
tone = "accent",
size = "md",
className,
...props
}: MeterProps) {
const showValue = showValueLabel ?? label != null;
return (
<AriaMeter {...props} className={cx("flex w-full flex-col gap-1.5", className)}>
{({ percentage, valueText }) => (
<>
{label != null || showValue ? (
<div className="flex items-baseline justify-between gap-3">
{label != null ? <Label className="min-w-0 truncate">{label}</Label> : <span />}
{showValue ? (
<span className="shrink-0 text-body-medium text-text-secondary tabular-nums">{valueText}</span>
) : null}
</div>
) : null}
<ProgressTrack percentage={percentage} size={size} className={tones[tone]} />
{description ? <span className="text-caption-1-medium text-text-secondary">{description}</span> : null}
</>
)}
</AriaMeter>
);
}

View file

@ -0,0 +1,197 @@
"use client";
import type { ReactNode } from "react";
import { ProgressBar as AriaProgressBar } from "react-aria-components";
import type { ProgressBarProps as AriaProgressBarProps } from "react-aria-components";
import { Label } from "@/components/base/input/label";
import { SpinnerGlyph } from "@/components/base/spinner/spinner";
import { useDirection } from "@/components/foundations/direction/direction";
import { cx, sortCx } from "@/utils/cx";
/**
* Progress — how far along a task is: an upload, an import, an onboarding
* checklist. Built on React Aria's ProgressBar, so it is announced with its
* label and value, and formats the value for the locale.
*
* <ProgressBar label="Uploading Q3-report.pdf" value={64} />
* <ProgressBar label="Preparing export" isIndeterminate />
* <ProgressCircle value={64} aria-label="Upload" />
*
* The bar is the agent limits card's rounded rail with an accent fill, on
* File Upload's track colour. The fill moves by translate rather than width, so it eases
* to each new value on the compositor (500ms, the card's ease-out).
*
* Indeterminate, a band of light sweeps the track, drawn like Spinner and
* Composer Loader: a crisp core with a rounded head, a tight glow and a wide
* bloom that bleed a few pixels past the rail, the soft layers pulled back
* so the sharp head leads. It runs right to left in RTL. The ring's
* indeterminate state is Spinner's orbiting light. Reduced motion slows the
* sweep rather than stopping it.
*/
export type ProgressSize = "sm" | "md" | "lg";
const EASE = "ease-[cubic-bezier(0.22,1,0.36,1)]";
const styles = sortCx({
track: {
sm: "h-1",
md: "h-1.5",
lg: "h-2",
},
ring: {
sm: { box: 16, line: 2, spinner: "sm" },
md: { box: 24, line: 2.5, spinner: "lg" },
lg: { box: 32, line: 3, spinner: "xl" },
},
});
/* -------------------------------------------------------------------- bar */
export interface ProgressBarProps extends Omit<AriaProgressBarProps, "children" | "className"> {
label?: ReactNode;
/** Show the formatted value at the end of the label row. Default: shown with a label. */
showValueLabel?: boolean;
/** Helper text under the bar. */
description?: ReactNode;
/** Track thickness: 4, 6 (default), or 8px. */
size?: ProgressSize;
className?: string;
}
/** The sweeping band of light for an indeterminate bar. */
function Sweep() {
return (
// The clip keeps the light inside the rail's length but lets the glow
// and bloom bleed past its thickness.
<span aria-hidden className="absolute inset-0 [clip-path:inset(-8px_0_round_9999px)]">
<span
className={cx(
"absolute inset-y-0 left-0 w-2/5",
"animate-[bui-progress-sweep_1.6s_cubic-bezier(0.65,0,0.35,1)_infinite]",
"rtl:[animation-direction:reverse] motion-reduce:[animation-duration:4s]",
)}
>
<span className="absolute inset-0 rtl:-scale-x-100">
<span className="absolute -inset-y-[5px] right-[10px] left-0 rounded-full bg-linear-to-r from-transparent to-current opacity-30 blur-[5px]" />
<span className="absolute -inset-y-[1.5px] right-[4px] left-[25%] rounded-full bg-linear-to-r from-transparent to-current opacity-60 blur-[2px]" />
<span className="absolute inset-y-0 right-0 left-[35%] rounded-full bg-linear-to-r from-transparent to-current" />
</span>
</span>
</span>
);
}
export interface ProgressTrackProps {
/** 0 to 100. */
percentage: number;
size?: ProgressSize;
/** Draw the indeterminate sweep instead of a fill. */
isIndeterminate?: boolean;
/** Colour of the fill, from the text colour. Default accent. */
className?: string;
}
/** The rail and its fill, shared by ProgressBar and Meter. */
export function ProgressTrack({ percentage, size = "md", isIndeterminate = false, className }: ProgressTrackProps) {
const direction = useDirection();
return (
// The rail is File Upload's ring track colour, which keeps an edge on
// every surface in both themes (chart-track matches dark cards).
<div className={cx("relative w-full rounded-full bg-border-button-default text-accent-500", styles.track[size], className)}>
{isIndeterminate ? (
<Sweep />
) : (
<span className="absolute inset-0 overflow-hidden rounded-full">
<span
className={cx("absolute inset-0 rounded-full bg-current transition-[translate] duration-500 motion-reduce:transition-none", EASE)}
style={{ translate: `${(direction === "rtl" ? 1 : -1) * (100 - Math.min(100, Math.max(0, percentage)))}% 0` }}
/>
</span>
)}
</div>
);
}
export function ProgressBar({
label,
showValueLabel,
description,
size = "md",
isIndeterminate,
className,
...props
}: ProgressBarProps) {
const showValue = showValueLabel ?? (label != null && !isIndeterminate);
return (
<AriaProgressBar {...props} isIndeterminate={isIndeterminate} className={cx("flex w-full flex-col gap-1.5", className)}>
{({ percentage = 0, valueText }) => (
<>
{label != null || showValue ? (
<div className="flex items-baseline justify-between gap-3">
{label != null ? <Label className="min-w-0 truncate">{label}</Label> : <span />}
{showValue ? (
<span className="shrink-0 text-body-medium text-text-secondary tabular-nums">{valueText}</span>
) : null}
</div>
) : null}
<ProgressTrack percentage={percentage} size={size} isIndeterminate={isIndeterminate} />
{description ? <span className="text-caption-1-medium text-text-secondary">{description}</span> : null}
</>
)}
</AriaProgressBar>
);
}
/* ------------------------------------------------------------------- ring */
export interface ProgressCircleProps extends Omit<AriaProgressBarProps, "children" | "className"> {
/** 16, 24 (default), or 32px. */
size?: ProgressSize;
/** Colour of the arc, from the text colour. Default accent. */
className?: string;
}
/**
* A ring for tight spots: a file tile, a table cell, a checklist header. Name
* it with `aria-label`. The arc draws by stroke-dashoffset, a paint-only
* change on a few pixels of SVG, eased like the bar.
*/
export function ProgressCircle({ size = "md", isIndeterminate, className, ...props }: ProgressCircleProps) {
const { box, line, spinner } = styles.ring[size];
const radius = (box - line) / 2;
const circumference = 2 * Math.PI * radius;
return (
<AriaProgressBar {...props} isIndeterminate={isIndeterminate} className="inline-flex shrink-0">
{({ percentage = 0 }) =>
isIndeterminate ? (
<SpinnerGlyph size={spinner as "sm" | "lg" | "xl"} className={className} />
) : (
<svg
aria-hidden
width={box}
height={box}
viewBox={`0 0 ${box} ${box}`}
className={cx("-rotate-90 text-accent-500", className)}
>
<circle cx={box / 2} cy={box / 2} r={radius} fill="none" strokeWidth={line} className="stroke-border-button-default" />
<circle
cx={box / 2}
cy={box / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={line}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - percentage / 100)}
className={cx("transition-[stroke-dashoffset] duration-500 motion-reduce:transition-none", EASE)}
/>
</svg>
)
}
</AriaProgressBar>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { useDirection } from "@/components/foundations/direction/direction";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode, Ref } from "react";
import {
ToggleButton as AriaToggleButton,
ToggleButtonGroup as AriaToggleButtonGroup,
} from "react-aria-components";
import type {
ToggleButtonGroupProps as AriaToggleButtonGroupProps,
ToggleButtonProps as AriaToggleButtonProps,
} from "react-aria-components";
import { cx } from "@/utils/cx";
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
/**
* Figma source: Board UI → dashboard 1 Weekly/Monthly/Yearly switch
* (node 3731:3096).
*
* Single-select segmented control.
* track bg background/tertiary/default, p 4, radius 10 (radius/2lg), gap 2
* segment px 10, py 4, radius/md (6px)
* selected bg segmented-control/selected (white in light), shadow/2xs, Body 1/Medium,
* text/primary
* unselected transparent, Body 1/Regular, text/secondary
*
* Built on react-aria ToggleButtonGroup with single selection that can't be
* emptied — arrow keys move focus, Space/Enter selects.
*/
/**
* `solid` (default) is the Figma design — tertiary track, white thumb behind
* the selected segment. `plain` drops the track background and the thumb; the
* selected segment reads only through its text weight/color.
*/
export type SegmentedControlVariant = "solid" | "plain";
export interface SegmentedControlProps
extends Omit<AriaToggleButtonGroupProps, "selectionMode" | "disallowEmptySelection"> {
children?: ReactNode;
variant?: SegmentedControlVariant;
ref?: Ref<HTMLDivElement>;
}
type Thumb = { left: number; top: number; width: number; height: number };
export function SegmentedControl({ className, children, variant = "solid", ref, ...props }: SegmentedControlProps) {
const direction = useDirection();
const innerRef = useRef<HTMLDivElement>(null);
const [thumb, setThumb] = useState<Thumb | null>(null);
useIsomorphicLayoutEffect(() => {
const el = innerRef.current;
if (!el) return;
const measure = () => {
const selected = el.querySelector<HTMLElement>("[data-selected]");
if (selected) {
setThumb({
left: selected.offsetLeft,
top: selected.offsetTop,
width: selected.offsetWidth,
height: selected.offsetHeight,
});
}
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
const observeItems = () => el.querySelectorAll("button").forEach(item => ro.observe(item));
observeItems();
const mo = new MutationObserver(() => { observeItems(); measure(); });
mo.observe(el, { attributes: true, childList: true, characterData: true, subtree: true, attributeFilter: ["data-selected"] });
return () => {
mo.disconnect();
ro.disconnect();
};
}, [direction]);
const setRefs = (node: HTMLDivElement | null) => {
innerRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
};
return (
<AriaToggleButtonGroup
ref={setRefs}
selectionMode="single"
disallowEmptySelection
{...props}
className={(state) =>
cx(
"relative inline-flex items-start gap-0.5 rounded-2lg",
variant === "solid" && "bg-segmented-control-background p-1",
typeof className === "function" ? className(state) : className,
)
}
>
{variant === "solid" && thumb && (
<span
aria-hidden
className="pointer-events-none absolute left-0 top-0 rounded-md bg-segmented-control-selected-background shadow-2xs transition-[transform,width,height] duration-200 ease"
style={{
transform: `translate(${thumb.left}px, ${thumb.top}px)`,
width: thumb.width,
height: thumb.height,
}}
/>
)}
{children}
</AriaToggleButtonGroup>
);
}
export interface SegmentedControlItemProps extends Omit<AriaToggleButtonProps, "children"> {
children?: ReactNode;
ref?: Ref<HTMLButtonElement>;
}
export function SegmentedControlItem({
className,
children,
ref,
...props
}: SegmentedControlItemProps) {
return (
<AriaToggleButton
ref={ref}
{...props}
className={(state) =>
cx(
"relative z-10 inline-flex cursor-pointer items-center justify-center rounded-md px-2.5 py-1 text-center whitespace-nowrap",
"transition-colors duration-200 ease",
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
state.isSelected
? "text-body-medium text-text-primary"
: "text-body-regular text-text-secondary hover:text-text-primary",
state.isDisabled && "cursor-not-allowed opacity-50",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
</AriaToggleButton>
);
}

View file

@ -0,0 +1,95 @@
"use client";
import type { CSSProperties, ReactNode } from "react";
import { cx, sortCx } from "@/utils/cx";
/**
* Skeleton — placeholders in the shape of the content that is on its way.
*
* <Skeleton shape="circle" className="size-10" />
* <SkeletonText lines={3} />
* <Skeleton isLoaded={user != null}><Avatar … /></Skeleton>
*
* Blocks are tinted from the text colour at 8%, so they show on any surface
* in both themes (a fixed grey disappears on dark cards). A sheen of light
* sweeps each one, drawn in layers like Spinner and Progress: a crisp 2px
* line leads, a tight glow and a wide bloom trail it. It mirrors in RTL and
* gives way to a gentle opacity pulse under reduced motion. The sheen lives
* in globals.css (`.bui-skeleton`).
*
* With children, a Skeleton sizes itself to them: it lays the real content
* out invisibly, then, once `isLoaded`, cross-fades the block away while the
* content sharpens in from a 4px blur.
*
* Skeletons are hidden from assistive technology; mark the region that is
* loading with `aria-busy`.
*/
export type SkeletonShape = "rect" | "text" | "circle";
const styles = sortCx({
block: "bui-skeleton block bg-text-primary/8",
shape: {
rect: "rounded-xl",
text: "rounded-md",
circle: "rounded-full",
},
});
const FADE = "transition-[opacity,filter] duration-300 ease-out motion-reduce:transition-none";
export interface SkeletonProps {
/** `rect` (radius 12, default), `text` (radius 6), or `circle`. */
shape?: SkeletonShape;
/** With children: show them once true, blurring them in. */
isLoaded?: boolean;
/** Size and placement, e.g. `h-4 w-40` or `size-10`. */
className?: string;
style?: CSSProperties;
/** Real content to measure the placeholder from and reveal when loaded. */
children?: ReactNode;
}
export function Skeleton({ shape = "rect", isLoaded = false, className, style, children }: SkeletonProps) {
if (children === undefined) {
return <span aria-hidden className={cx(styles.block, "relative", styles.shape[shape], className)} style={style} />;
}
return (
<span className={cx("relative block", className)} style={style}>
<span
aria-hidden={!isLoaded || undefined}
inert={!isLoaded}
className={cx("block", FADE, isLoaded ? "opacity-100 blur-[0px]" : "opacity-0 motion-safe:blur-[4px]")}
>
{children}
</span>
<span
aria-hidden
className={cx(styles.block, styles.shape[shape], "pointer-events-none absolute inset-0", FADE, isLoaded && "opacity-0")}
/>
</span>
);
}
export interface SkeletonTextProps {
/** Number of lines. Default 3; the last one runs 60% wide. */
lines?: number;
/** Line height, matched to the text it stands in for. Default `h-3.5` (Body 1). */
lineClassName?: string;
className?: string;
}
export function SkeletonText({ lines = 3, lineClassName = "h-3.5", className }: SkeletonTextProps) {
return (
<span aria-hidden className={cx("flex w-full flex-col gap-2.5", className)}>
{Array.from({ length: lines }, (_, index) => (
<Skeleton
key={index}
shape="text"
className={cx(lineClassName, index === lines - 1 && lines > 1 ? "w-3/5" : "w-full")}
/>
))}
</span>
);
}

View file

@ -0,0 +1,129 @@
"use client";
import type { CSSProperties } from "react";
import { ProgressBar as AriaProgressBar } from "react-aria-components";
import { cx } from "@/utils/cx";
/**
* Spinner — an orbiting light for work of unknown length.
*
* <Spinner /> // 20px, accent
* <Spinner size="sm" className="text-white" /> // inside a primary button
*
* The light is drawn the way Composer Loader draws its band: the same arc
* at three scales, a crisp line with a rounded head, a tight glow and a wide
* bloom, each on its own blur. The soft layers carry shorter arcs and turn a
* few degrees behind, so the sharp head always leads and the glow gathers
* just behind it like the tail of a comet. A faint track completes the
* circle. Everything is drawn in `currentColor`, so a text colour sets it.
*
* Announced as an indeterminate progress bar labelled "Loading" (override
* with `aria-label`). Pass `aria-hidden` where the surrounding control
* already says it is busy, e.g. a pending button. Reduced motion slows the
* orbit rather than stopping it: the motion is the message.
*/
export type SpinnerSize = "xs" | "sm" | "md" | "lg" | "xl";
/** Box size and line width in px. */
const SIZES: Record<SpinnerSize, { box: number; line: number }> = {
xs: { box: 12, line: 1.5 },
sm: { box: 16, line: 2 },
md: { box: 20, line: 2 },
lg: { box: 24, line: 2.5 },
xl: { box: 32, line: 3 },
};
/** One scale of the light: a conic arc masked to a ring, blurred by its wrapper. */
function Arc({
box,
line,
width,
blur,
opacity,
arc,
lag,
}: {
box: number;
line: number;
/** Ring thickness, px. */
width: number;
blur: number;
opacity: number;
/** Arc length in degrees; the head sits at 12 o'clock before the turn. */
arc: number;
/** Degrees this layer trails the crisp head. */
lag: number;
}) {
const center = box / 2 - line / 2;
const inner = center - width / 2;
const outer = center + width / 2;
// The wrapper is grown to hold the ring plus its blur, so nothing clips.
const bleed = Math.max(0, outer + blur * 2 - box / 2);
const ring = `radial-gradient(circle, transparent ${inner}px, #000 ${inner + 0.5}px, #000 ${outer - 0.5}px, transparent ${outer}px)`;
const style: CSSProperties = {
inset: -bleed,
filter: blur > 0 ? `blur(${blur}px)` : undefined,
opacity,
rotate: `${-lag}deg`,
};
return (
<span aria-hidden className="absolute" style={style}>
<span
className="absolute inset-0"
style={{
background: `conic-gradient(transparent ${360 - arc}deg, currentColor 360deg)`,
maskImage: ring,
WebkitMaskImage: ring,
}}
/>
</span>
);
}
export interface SpinnerProps {
/** 12, 16, 20 (default), 24, or 32px. */
size?: SpinnerSize;
/** Draw the faint full circle under the light. Default true. */
track?: boolean;
/** Accessible name. Default "Loading". */
"aria-label"?: string;
/** Hide it from assistive technology when a parent already announces the wait. */
"aria-hidden"?: boolean;
/** Colour comes from the text colour, e.g. `text-white`. Default accent. */
className?: string;
}
export function SpinnerGlyph({ size = "md", track = true, className }: Omit<SpinnerProps, "aria-label" | "aria-hidden">) {
const { box, line } = SIZES[size];
return (
<span
aria-hidden
className={cx("relative inline-flex shrink-0 text-accent-500", className)}
style={{ width: box, height: box }}
>
{track ? (
<span className="absolute inset-0 rounded-full border-current opacity-15" style={{ borderWidth: line }} />
) : null}
<span className="absolute inset-0 animate-spin [animation-duration:900ms] motion-reduce:[animation-duration:2400ms]">
<Arc box={box} line={line} width={line * 4} blur={line * 1.6} opacity={0.32} arc={110} lag={22} />
<Arc box={box} line={line} width={line * 2} blur={line * 0.6} opacity={0.6} arc={200} lag={9} />
<Arc box={box} line={line} width={line} blur={0} opacity={1} arc={290} lag={0} />
{/* The crisp line's rounded head, at the arc's leading end. */}
<span
className="absolute left-1/2 top-0 rounded-full bg-current"
style={{ width: line, height: line, translate: "-50% 0" }}
/>
</span>
</span>
);
}
export function Spinner({ size = "md", track = true, "aria-label": ariaLabel = "Loading", "aria-hidden": ariaHidden, className }: SpinnerProps) {
if (ariaHidden) return <SpinnerGlyph size={size} track={track} className={className} />;
return (
<AriaProgressBar isIndeterminate aria-label={ariaLabel} className="inline-flex shrink-0">
<SpinnerGlyph size={size} track={track} className={className} />
</AriaProgressBar>
);
}

View file

@ -0,0 +1,172 @@
"use client";
import type { ReactNode, Ref } from "react";
import { Switch as AriaSwitch } from "react-aria-components";
import type { SwitchProps as AriaSwitchProps } from "react-aria-components";
import { cx, sortCx } from "@/utils/cx";
/**
* Figma source: Board UI → "Toogles" (node 3856:3807).
*
* Two shapes × three sizes, plus off / on:
* shape pill (Figma "fully rounded") — track & thumb rounded-full
* rectangle (Figma "rectangle") — track radius/md-scale, thumb radius/xs-scale
* size sm 28×16 · md 42×24 · lg 56×32 (track w×h; thumb = h − 2·pad)
*
* Anatomy (all values 1:1 with Figma):
* track off → bg background/tertiary/default (#ebebeb, no border)
* on → Button/Primary gradient (blue/500 → blue/600) + an inset
* ring + top highlight that scales with size. lg is exactly
* the shared `--shadow-checkbox-selected` token; md/sm scale
* the ring (0.75px / 0.5px) and highlight (1.5px / 1px).
* thumb theme-stable white gradient + contact shadow,
* slides by (w − thumb − 2·pad) between states.
* chip small embossed inset in the thumb centre:
* off → white→neutral-100 in light mode; in dark mode it matches
* the track so the mark reads like a hole through the thumb;
* on → switch-on-chip-start→end (accent mixes matching Figma's
* raw #2473fe→#0450e2 for blue), accent/600 border.
*
* The track visual lives in `SwitchTrack` so `Switch` and `SwitchCard` share it
* (mirrors how Checkbox / CheckboxCard share `CheckboxGlyph`). Built on
* react-aria Switch: native toggle semantics, keyboard support, focus-visible
* ring, optional label via `children`.
*/
export type SwitchSize = "sm" | "md" | "lg";
export type SwitchShape = "pill" | "rectangle";
export const switchSizes = sortCx({
sm: {
track: "h-4 w-7",
trackRadius: { pill: "rounded-full", rectangle: "rounded-[3px]" },
onShadow: "shadow-[inset_0_1px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.5px_var(--color-accent-500)]",
thumb: "size-3",
thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" },
offset: "start-0.5 top-0.5",
travel: "translate-x-3 rtl:-translate-x-3",
chip: "size-[5px] border-[0.25px] shadow-[0_2px_2px_0_rgb(0_0_0/0.03)]",
chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.5px]" },
},
md: {
track: "h-6 w-[42px]",
trackRadius: { pill: "rounded-full", rectangle: "rounded-[4.5px]" },
onShadow: "shadow-[inset_0_1.5px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.75px_var(--color-accent-500)]",
thumb: "size-[18px]",
thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1.5px]" },
offset: "start-[3px] top-[3px]",
travel: "translate-x-[18px] rtl:-translate-x-[18px]",
chip: "size-[7.5px] border-[0.375px] shadow-[0_3px_3px_0_rgb(0_0_0/0.03)]",
chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.75px]" },
},
lg: {
track: "h-8 w-14",
trackRadius: { pill: "rounded-full", rectangle: "rounded-md" },
onShadow: "shadow-checkbox-selected",
thumb: "size-6",
thumbRadius: { pill: "rounded-full", rectangle: "rounded-xs" },
offset: "start-1 top-1",
travel: "translate-x-6 rtl:-translate-x-6",
chip: "size-[10px] border-[0.5px] shadow-[0_4px_4px_0_rgb(0_0_0/0.03)]",
chipRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" },
},
});
export interface SwitchVisualState {
isSelected: boolean;
isDisabled: boolean;
isFocusVisible: boolean;
}
/**
* The track + thumb + chip visual. Pure presentation — the react-aria Switch
* (in `Switch` or `SwitchCard`) owns the input and passes its render state in.
*/
export function SwitchTrack({
state,
size = "md",
shape = "pill",
}: {
state: SwitchVisualState;
size?: SwitchSize;
shape?: SwitchShape;
}) {
const s = switchSizes[size];
return (
<span
aria-hidden
className={cx(
"relative shrink-0 transition-colors duration-200 ease",
s.track,
s.trackRadius[shape],
state.isSelected
? cx("bg-linear-to-b from-accent-500 to-accent-600", s.onShadow)
: "bg-background-tertiary-default",
state.isDisabled && "opacity-50",
state.isFocusVisible && "ring-2 ring-border-focus-ring ring-offset-2",
)}
>
{/* Thumb */}
<span
className={cx(
"absolute flex items-center justify-center",
"bg-linear-to-b from-control-indicator-background from-[43.837%] to-control-indicator-background-subtle",
"shadow-[0_3px_3px_0_rgb(0_0_0/0.03),0_0.75px_0_0_rgb(0_0_0/0.05)]",
"transition-transform duration-200 ease",
s.thumb,
s.thumbRadius[shape],
s.offset,
state.isSelected && s.travel,
)}
>
{/* Embossed inner chip. Gradient runs bottom→top (`to-t`) — equivalent
to the Figma layer's vertical flip — but WITHOUT a transform, so the
chip stays pixel-snapped/centred (no blurry 1px shift at sub-pixel
sizes) and its drop shadow points down instead of up. */}
<span
className={cx(
"border-solid bg-linear-to-t from-[43.837%]",
s.chip,
s.chipRadius[shape],
state.isSelected
? "border-accent-600 from-switch-on-chip-start to-switch-on-chip-end"
: "border-border-button-default/50 from-switch-off-chip-start to-switch-off-chip-end",
)}
/>
</span>
</span>
);
}
export interface SwitchProps extends Omit<AriaSwitchProps, "children"> {
children?: ReactNode;
size?: SwitchSize;
shape?: SwitchShape;
ref?: Ref<HTMLLabelElement>;
}
export function Switch({ className, children, size = "md", shape = "pill", ref, ...props }: SwitchProps) {
return (
<AriaSwitch
ref={ref}
{...props}
className={(state) =>
cx(
"group inline-flex items-center gap-2 select-none",
state.isDisabled ? "cursor-not-allowed" : "cursor-pointer",
typeof className === "function" ? className(state) : className,
)
}
>
{(state) => (
<>
<SwitchTrack state={state} size={size} shape={shape} />
{children != null && children !== false && (
<span className="text-body-medium text-text-primary">{children}</span>
)}
</>
)}
</AriaSwitch>
);
}