jhxk68cdy: Add the five approved button styles, the glossy icon button and a dev story page

This commit is contained in:
2026-10-19 21:55:26 -06:00
parent 3306a24d33
commit dd47cfd911
7 changed files with 1010 additions and 3 deletions
+79
View File
@@ -0,0 +1,79 @@
// Every button style and state, in one theme scope. Used by the dev story page
// (src/app/dev/buttons) and the button tests.
import type { CSSProperties, ReactNode } from "react";
import type { Theme } from "@/lib/theme";
import { DepthButton, FlipButton, GrowButton, IconButton, SendIcon, SparkleButton, SweepButton } from "./Buttons";
const scope: CSSProperties = {
background: "var(--bg)",
color: "var(--ink)",
padding: "32px 28px",
display: "flex",
flexDirection: "column",
gap: 28,
};
const row: CSSProperties = { display: "flex", flexWrap: "wrap", alignItems: "center", gap: 14 };
const heading: CSSProperties = { margin: "0 0 10px", fontSize: 12, fontWeight: 700, letterSpacing: ".12em", textTransform: "uppercase", color: "var(--ink-3)" };
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<section>
<h2 style={heading}>{title}</h2>
<div style={row}>{children}</div>
</section>
);
}
const FlameIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round">
<path d="M12 21c4 0 7-2.8 7-6.8 0-4.5-4-6.7-5-11.2-2.3 1.7-3.5 4-3.2 6.5C9.5 9 8.6 8 8.3 6.6 6.3 8.5 5 10.9 5 14.2 5 18.2 8 21 12 21z" />
</svg>
);
const TrashIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 7h16M9 7V4.5h6V7M6.5 7l1 13h9l1-13" />
</svg>
);
const PlayIcon = (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 5.5v13l11-6.5z" fill="currentColor" />
</svg>
);
export function ButtonGallery({ theme }: { theme: Theme }) {
return (
<div data-theme={theme} style={scope} aria-label={`Buttons, ${theme} theme`} role="region">
<h1 className="type-title type-title-strong" style={{ margin: 0 }}>
{theme === "dark" ? "Dark" : "Light"} theme
</h1>
<Section title="Flip reveal: ready, disabled, posting">
<FlipButton state="ready" type="button" />
<FlipButton state="disabled" type="button" />
<FlipButton state="posting" type="button" />
</Section>
<Section title="Circle expand">
<GrowButton label="3 new posts" />
</Section>
<Section title="Outline fill sweep">
<SweepButton label="Business" selected />
<SweepButton label="Media" selected={false} />
<SweepButton label="Cancel" />
</Section>
<Section title="Soft depth, rising liquid">
<div style={{ width: 280 }}>
<DepthButton label="Trending minnects" sublabel="What people are watching" icon={FlameIcon} />
</div>
</Section>
<Section title="Sparkle glow: sign in, mobile new post">
<SparkleButton label="Sign in" />
<SparkleButton variant="fab" label="New post" />
</Section>
<Section title="Glossy crimson icon button">
<IconButton label="Send comment" icon={<SendIcon />} />
<IconButton label="Delete post" icon={TrashIcon} size="sm" />
<IconButton label="Play video" icon={PlayIcon} shape="round" />
<IconButton label="Send comment (disabled)" icon={<SendIcon />} disabled />
</Section>
</div>
);
}
+239
View File
@@ -0,0 +1,239 @@
// Button system (spec "Buttons", ADR 0001). Styles live in src/styles/buttons.css.
// Each component renders a native <button>; extra button props pass through.
import type { ButtonHTMLAttributes, ReactNode } from "react";
type NativeButton = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children">;
const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(" ");
const svgProps = {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
export function SendIcon() {
return (
<svg {...svgProps} strokeWidth={2}>
<path d="M21 3 10.5 13.5" />
<path d="M21 3 14.5 21l-4-7.5L3 9.5z" />
</svg>
);
}
export function ArrowUpIcon() {
return (
<svg {...svgProps} strokeWidth={2.4}>
<path d="M12 19V5M6 11l6-6 6 6" />
</svg>
);
}
export function PlusIcon() {
return (
<svg {...svgProps} strokeWidth={2.2}>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
/* 1. Flip reveal ------------------------------------------------------- */
export type FlipState = "ready" | "disabled" | "posting";
export interface FlipButtonProps extends NativeButton {
state?: FlipState;
/** Face label in the ready and disabled states. */
label?: string;
/** Panel that slides up on hover when ready. */
hoverLabel?: string;
/** Panel that slides up on hover when there is nothing to post. */
disabledLabel?: string;
/** Face label while posting. */
busyLabel?: string;
}
/** The labels each state shows: face text, hover panel text, and whether the send icon shows. */
export function flipLabels(
state: FlipState,
labels: { label?: string; hoverLabel?: string; disabledLabel?: string; busyLabel?: string } = {},
) {
const { label = "Post", hoverLabel = "Publish", disabledLabel = "Write first", busyLabel = "Posting" } = labels;
if (state === "posting") return { front: busyLabel, back: null, icon: false };
if (state === "disabled") return { front: label, back: disabledLabel, icon: false };
return { front: label, back: hoverLabel, icon: true };
}
export function FlipButton({
state = "ready",
label,
hoverLabel,
disabledLabel,
busyLabel,
className,
type = "submit",
disabled,
...rest
}: FlipButtonProps) {
const { front, back, icon } = flipLabels(state, { label, hoverLabel, disabledLabel, busyLabel });
const busy = state === "posting";
return (
<button
{...rest}
type={type}
className={cx("btn-flip", busy && "btn-flip-busy", className)}
disabled={disabled || state !== "ready"}
aria-busy={busy || undefined}
>
<span className="btn-flip-front">
{front}
{icon ? <SendIcon /> : null}
</span>
{back ? (
<span className="btn-flip-back" aria-hidden="true">
{back}
{icon ? <SendIcon /> : null}
</span>
) : null}
</button>
);
}
/* 2. Circle expand ----------------------------------------------------- */
export interface GrowButtonProps extends NativeButton {
label: string;
icon?: ReactNode;
}
export function GrowButton({ label, icon, className, type = "button", ...rest }: GrowButtonProps) {
return (
<button {...rest} type={type} className={cx("btn-grow", className)}>
<span className="btn-grow-circle" aria-hidden="true">
{icon ?? <ArrowUpIcon />}
</span>
<span className="btn-grow-text">{label}</span>
</button>
);
}
/* 3. Outline fill sweep ------------------------------------------------ */
export interface SweepButtonProps extends NativeButton {
label: string;
/** Selected chip: stays filled. Sets aria-pressed when given. */
selected?: boolean;
}
export function SweepButton({ label, selected, className, type = "button", ...rest }: SweepButtonProps) {
return (
<button
{...rest}
type={type}
className={cx("btn-sweep", selected && "btn-sweep-on", className)}
aria-pressed={selected === undefined ? undefined : selected}
>
{label}
</button>
);
}
/* 4. Soft depth, rising liquid ----------------------------------------- */
export interface DepthButtonProps extends NativeButton {
label: string;
sublabel?: string;
icon?: ReactNode;
/** Trailing content, for example a chevron or the current theme name. */
trailing?: ReactNode;
}
export function DepthButton({ label, sublabel, icon, trailing, className, type = "button", ...rest }: DepthButtonProps) {
return (
<button {...rest} type={type} className={cx("btn-depth", className)}>
{icon ? (
<span className="btn-depth-icon" aria-hidden="true">
{icon}
</span>
) : null}
<span className="btn-depth-text">
<strong className="btn-depth-label">{label}</strong>
{sublabel ? <span className="btn-depth-sub">{sublabel}</span> : null}
</span>
{trailing}
</button>
);
}
/* 5. Sparkle glow ------------------------------------------------------ */
const SPARKS = Array.from({ length: 10 }, (_, i) => i);
function Sparks() {
return (
<span className="btn-spark-pts" aria-hidden="true">
{SPARKS.map((i) => (
<i key={i} />
))}
</span>
);
}
export interface SparkleButtonProps extends NativeButton {
/** "signin": folded corner and hexagon icon. "fab": mobile new-post, plus icon only. */
variant?: "signin" | "fab";
/** Visible text for "signin"; accessible name for "fab". */
label: string;
}
export function SparkleButton({ variant = "signin", label, className, type = "button", ...rest }: SparkleButtonProps) {
if (variant === "fab") {
return (
<button {...rest} type={type} aria-label={label} className={cx("btn-spark", "btn-spark-fab", className)}>
<Sparks />
<span className="btn-spark-plus">
<PlusIcon />
</span>
</button>
);
}
return (
<button {...rest} type={type} className={cx("btn-spark", className)}>
<span className="btn-spark-fold" aria-hidden="true" />
<Sparks />
<span className="btn-spark-inner">
<svg className="btn-spark-hex" viewBox="0 0 24 24" aria-hidden="true">
<polygon points="12 2 20.66 7 20.66 17 12 22 3.34 17 3.34 7" />
</svg>
{label}
</span>
</button>
);
}
/* Glossy crimson icon button ------------------------------------------- */
export interface IconButtonProps extends NativeButton {
/** Accessible name; the button shows only the icon. */
label: string;
icon: ReactNode;
shape?: "square" | "round";
size?: "md" | "sm";
}
export function IconButton({ label, icon, shape = "square", size = "md", className, type = "button", ...rest }: IconButtonProps) {
return (
<button
{...rest}
type={type}
aria-label={label}
title={rest.title ?? label}
className={cx("btn-gloss", shape === "round" && "btn-gloss-round", size === "sm" && "btn-gloss-sm", className)}
>
{icon}
</button>
);
}