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
+19
View File
@@ -0,0 +1,19 @@
// Dev-only story page for the button system (task jhxk68cdy). Not linked from
// the app and returns 404 in production builds. Shows every style in both themes.
import type { CSSProperties } from "react";
import { notFound } from "next/navigation";
import { ButtonGallery } from "@/components/ButtonGallery";
export const metadata = { title: "Buttons (dev)" };
const grid: CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(340px, 1fr))", minHeight: "100vh" };
export default function ButtonsDevPage() {
if (process.env.NODE_ENV === "production") notFound();
return (
<main style={grid}>
<ButtonGallery theme="dark" />
<ButtonGallery theme="light" />
</main>
);
}
+20 -3
View File
@@ -1,9 +1,11 @@
/* Global styles only (ADR 0001, section 6): layer order, tokens, reset, focus ring. */ /* Global styles only (ADR 0001, section 6): layer order, tokens, reset, focus ring. */
@layer reset, base; @layer reset, base;
/* Tokens. Dark is the default, so it applies even without data-theme. */ /* Tokens. Dark is the default, so it applies even without data-theme.
A nested [data-theme] element re-scopes the theme tokens (used by the dev button page). */
:root, :root,
:root[data-theme="dark"] { :root[data-theme="dark"],
[data-theme="dark"] {
--bg: #09090B; --bg: #09090B;
--tile: #121215; --tile: #121215;
--tile-2: #19191D; --tile-2: #19191D;
@@ -17,10 +19,16 @@
--danger: #FF6B70; --danger: #FF6B70;
--glass: rgba(18,18,21,.72); --glass: rgba(18,18,21,.72);
--shadow-pop: 0 10px 28px rgba(0,0,0,.5); --shadow-pop: 0 10px 28px rgba(0,0,0,.5);
/* Buttons: drop shadows and the soft-depth (neumorphic) light and shade. */
--shadow-btn: 0 10px 20px -6px rgba(0,0,0,.35);
--shadow-float: 0 14px 28px -12px rgba(0,0,0,.55);
--neu-d: rgba(0,0,0,.8);
--neu-l: rgba(255,255,255,.05);
color-scheme: dark; color-scheme: dark;
} }
:root[data-theme="light"] { :root[data-theme="light"],
[data-theme="light"] {
--bg: #EFEDE9; --bg: #EFEDE9;
--tile: #FBFAF8; --tile: #FBFAF8;
--tile-2: #F3F1EE; --tile-2: #F3F1EE;
@@ -34,6 +42,10 @@
--danger: #B3121A; --danger: #B3121A;
--glass: rgba(251,250,248,.74); --glass: rgba(251,250,248,.74);
--shadow-pop: 0 10px 28px rgba(26,28,31,.14); --shadow-pop: 0 10px 28px rgba(26,28,31,.14);
--shadow-btn: 0 10px 20px -6px rgba(26,28,31,.22);
--shadow-float: 0 14px 28px -12px rgba(26,28,31,.3);
--neu-d: rgba(26,28,31,.17);
--neu-l: rgba(255,255,255,.95);
color-scheme: light; color-scheme: light;
} }
@@ -42,6 +54,11 @@
--accent-hi: color-mix(in srgb, var(--accent), white 26%); --accent-hi: color-mix(in srgb, var(--accent), white 26%);
--accent-lo: color-mix(in srgb, var(--accent), black 30%); --accent-lo: color-mix(in srgb, var(--accent), black 30%);
--accent-soft: color-mix(in srgb, var(--accent), transparent 88%); --accent-soft: color-mix(in srgb, var(--accent), transparent 88%);
--accent-deep: color-mix(in srgb, var(--accent), black 55%);
--accent-glow: rgba(255,110,110,.75);
--accent-glow-0: rgba(255,110,110,0);
/* Text and marks on a crimson face: white in both themes. */
--on-accent: #FFFFFF;
/* HexAvatar initials: dark muted gradients, same in both themes. */ /* HexAvatar initials: dark muted gradients, same in both themes. */
--hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12); --hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12);
+1
View File
@@ -4,6 +4,7 @@ import { Cormorant_Garamond, Manrope } from "next/font/google";
import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme"; import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme";
import "./globals.css"; import "./globals.css";
import "../styles/type.css"; import "../styles/type.css";
import "../styles/buttons.css";
import "../styles/avatar.css"; import "../styles/avatar.css";
import "../styles/legacy.css"; import "../styles/legacy.css";
+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>
);
}
+492
View File
@@ -0,0 +1,492 @@
/*
* Button system (spec "Buttons", ADR 0001 sections 6 to 8).
* Five styles plus the glossy crimson icon button, ported from the mockup's
* recolored rules. Tokens only; white and black appear only on crimson faces.
* Every class sets margin, padding, font and color itself so the legacy
* `button` rules and any `button { color: inherit }` never leak in.
*/
/* Shared base for every style. */
.btn-flip,
.btn-grow,
.btn-sweep,
.btn-depth,
.btn-spark,
.btn-gloss {
appearance: none;
margin: 0;
border: 0;
font-family: inherit;
cursor: pointer;
position: relative;
overflow: hidden;
isolation: isolate;
opacity: 1;
-webkit-tap-highlight-color: transparent;
}
.btn-flip svg,
.btn-grow svg,
.btn-depth svg,
.btn-spark svg,
.btn-gloss svg { flex: none; }
/* 1. Flip reveal: Post. Ready, disabled ("write first") and busy ("posting"). */
.btn-flip {
height: 42px;
min-width: 118px;
padding: 0 22px;
border-radius: var(--r-ctl);
background: var(--ink);
color: var(--bg);
font-size: 13px;
font-weight: 800;
letter-spacing: .14em;
text-transform: uppercase;
box-shadow: var(--shadow-btn);
transition: transform .3s cubic-bezier(.23,1,.32,1);
}
.btn-flip .btn-flip-front {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 100%;
color: var(--bg);
transition: transform .6s cubic-bezier(.23,1,.32,1);
}
.btn-flip .btn-flip-back {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--on-accent);
background: linear-gradient(135deg, var(--accent-deep), var(--accent));
transform: translateY(100%);
transition: transform .6s cubic-bezier(.23,1,.32,1);
}
.btn-flip svg { width: 16px; height: 16px; }
.btn-flip:hover .btn-flip-front,
.btn-flip:focus-visible .btn-flip-front { transform: translateY(-200%); }
.btn-flip:hover .btn-flip-back,
.btn-flip:focus-visible .btn-flip-back { transform: none; }
.btn-flip:active:not(:disabled) { transform: scale(.95); }
/* Disabled: solid graphite face, readable text, never faded. */
.btn-flip:disabled {
opacity: 1;
cursor: not-allowed;
background: var(--tile-3);
color: var(--ink-2);
box-shadow: inset 0 0 0 1px var(--line-2);
}
.btn-flip:disabled .btn-flip-front { color: var(--ink-2); }
.btn-flip:disabled .btn-flip-back {
background: var(--tile-3);
color: var(--ink);
font-size: 12px;
}
/* Busy (posting): keeps the ink face, shows "Posting", never flips. */
.btn-flip.btn-flip-busy:disabled {
cursor: progress;
background: var(--ink);
color: var(--bg);
box-shadow: var(--shadow-btn);
}
.btn-flip.btn-flip-busy:disabled .btn-flip-front { color: var(--bg); transform: none; }
.btn-flip.btn-flip-busy .btn-flip-back { transform: translateY(100%); }
/* 2. Circle expand: the new-posts pill. */
.btn-grow {
display: block;
width: 178px;
height: 42px;
padding: 0;
border-radius: 999px;
background: var(--glass);
color: var(--ink);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: inset 0 0 0 1px var(--line-2), var(--shadow-float);
}
.btn-grow .btn-grow-circle {
position: absolute;
left: 4px;
top: 4px;
bottom: 4px;
width: 34px;
border-radius: 999px;
background: linear-gradient(180deg, var(--accent-hi), var(--accent) 55%, var(--accent-lo));
transition: width .45s cubic-bezier(.65,0,.076,1);
}
.btn-grow .btn-grow-circle svg {
position: absolute;
left: 9px;
top: 9px;
width: 16px;
height: 16px;
color: var(--on-accent);
transition: transform .45s cubic-bezier(.65,0,.076,1);
}
.btn-grow .btn-grow-text {
position: relative;
z-index: 1;
display: block;
padding-left: 40px;
line-height: 42px;
font-size: 11.5px;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
white-space: nowrap;
color: var(--ink);
transition: color .45s cubic-bezier(.65,0,.076,1);
}
.btn-grow:hover .btn-grow-circle,
.btn-grow:focus-visible .btn-grow-circle { width: calc(100% - 8px); }
.btn-grow:hover .btn-grow-circle svg,
.btn-grow:focus-visible .btn-grow-circle svg { transform: translateY(-3px); }
.btn-grow:hover .btn-grow-text,
.btn-grow:focus-visible .btn-grow-text { color: var(--on-accent); }
.btn-grow:active { transform: scale(.97); }
/* 3. Outline fill sweep: category chips and Cancel. Selected stays filled. */
.btn-sweep {
display: inline-flex;
align-items: center;
justify-content: center;
height: 34px;
padding: 0 15px;
border-radius: 999px;
background: transparent;
color: var(--ink);
font-size: 12.5px;
font-weight: 600;
white-space: nowrap;
box-shadow: inset 0 0 0 1.5px var(--line-2);
transition: color .3s, box-shadow .3s, transform .15s;
}
.btn-sweep::before {
content: "";
position: absolute;
z-index: -1;
width: 170px;
height: 170px;
border-radius: 50%;
background: var(--ink);
top: 100%;
left: 100%;
transition: top .35s var(--ease), left .35s var(--ease);
}
.btn-sweep:hover,
.btn-sweep:focus-visible,
.btn-sweep.btn-sweep-on {
color: var(--bg);
box-shadow: inset 0 0 0 1.5px var(--ink);
}
.btn-sweep:hover::before,
.btn-sweep:focus-visible::before,
.btn-sweep.btn-sweep-on::before { top: -70px; left: -40px; }
.btn-sweep:active { transform: scale(.95); }
/* 4. Soft depth, rising liquid: mobile More sheet rows and the theme switch. */
.btn-depth {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
padding: 14px;
border-radius: var(--r-card);
background: var(--bg);
color: var(--ink);
text-align: left;
font-size: 14px;
box-shadow: 6px 6px 14px var(--neu-d), -6px -6px 14px var(--neu-l);
transition: color .3s, box-shadow .25s;
}
.btn-depth::before,
.btn-depth::after {
content: "";
position: absolute;
width: 140%;
height: 240%;
border-radius: 50%;
z-index: -1;
transition: all .5s .05s cubic-bezier(.55,0,.1,1);
}
.btn-depth::before {
left: 50%;
top: 100%;
transform: translateX(-50%) scaleX(1.25);
background: color-mix(in srgb, var(--accent), transparent 82%);
}
.btn-depth::after {
left: 55%;
top: 180%;
transform: translateX(-50%) scaleX(1.45);
background: var(--accent);
}
.btn-depth .btn-depth-icon {
width: 44px;
height: 44px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-ctl);
background: var(--tile-2);
color: var(--ink);
transition: background-color .3s, color .3s;
}
.btn-depth .btn-depth-icon svg { width: 20px; height: 20px; }
.btn-depth .btn-depth-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.btn-depth .btn-depth-label { font-size: 15px; font-weight: 700; color: inherit; }
.btn-depth .btn-depth-sub { font-size: 12.5px; color: var(--ink-3); transition: color .3s; }
.btn-depth:hover,
.btn-depth:focus-visible { color: var(--on-accent); }
.btn-depth:hover::before,
.btn-depth:focus-visible::before {
top: -35%;
background: var(--accent);
transform: translateX(-50%) scaleY(1.3) scaleX(.8);
}
.btn-depth:hover::after,
.btn-depth:focus-visible::after { top: -45%; transform: translateX(-50%) scaleY(1.3) scaleX(.8); }
.btn-depth:hover .btn-depth-sub,
.btn-depth:focus-visible .btn-depth-sub { color: color-mix(in srgb, white 82%, transparent); }
.btn-depth:hover .btn-depth-icon,
.btn-depth:focus-visible .btn-depth-icon { background: color-mix(in srgb, white 16%, transparent); color: var(--on-accent); }
.btn-depth:active { box-shadow: inset 4px 4px 12px var(--neu-d), inset -4px -4px 12px var(--neu-l); }
/* 5. Sparkle glow: Sign in (fold + hexagon) and the mobile new-post button (plus). */
.btn-spark {
--spark-round: 14px;
display: flex;
align-items: center;
justify-content: center;
min-width: 180px;
height: 50px;
padding: 0 22px;
border-radius: var(--spark-round);
color: var(--on-accent);
background:
radial-gradient(65% 65% at 50% 100%, var(--accent-glow) 0%, var(--accent-glow-0) 100%),
linear-gradient(0deg, var(--accent), var(--accent));
box-shadow: 0 18px 36px -14px color-mix(in srgb, var(--accent), transparent 15%);
transition: transform .25s;
}
.btn-spark::before,
.btn-spark::after {
content: "";
position: absolute;
inset: var(--spark-inset);
border-radius: calc(var(--spark-round) - var(--spark-inset));
z-index: 0;
transition: all .5s ease-in-out;
}
.btn-spark::before {
--spark-inset: 1px;
background: linear-gradient(178deg, color-mix(in srgb, white 24%, transparent) 0%, transparent 100%);
}
.btn-spark::after {
--spark-inset: 2px;
background:
radial-gradient(65% 65% at 50% 100%, var(--accent-glow) 0%, var(--accent-glow-0) 100%),
linear-gradient(0deg, var(--accent), var(--accent));
}
.btn-spark:active { transform: scale(.96); }
.btn-spark .btn-spark-fold {
z-index: 1;
position: absolute;
top: 0;
right: 0;
width: 1rem;
height: 1rem;
transition: all .5s ease-in-out;
background: radial-gradient(100% 75% at 55%, var(--accent-glow) 0%, var(--accent-glow-0) 100%);
box-shadow: 0 0 3px black;
border-bottom-left-radius: .5rem;
border-top-right-radius: var(--spark-round);
}
.btn-spark .btn-spark-fold::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 150%;
height: 150%;
transform: rotate(45deg) translateY(-18px);
background: var(--bg);
pointer-events: none;
}
.btn-spark:hover .btn-spark-fold,
.btn-spark:focus-visible .btn-spark-fold { margin-top: -1rem; margin-right: -1rem; }
.btn-spark .btn-spark-pts { position: absolute; inset: 0; overflow: hidden; pointer-events: none; z-index: 1; }
.btn-spark .btn-spark-pts i {
position: absolute;
bottom: -10px;
width: 2px;
height: 2px;
border-radius: 50%;
background: var(--on-accent);
animation: btn-spark-rise infinite ease-in-out;
}
@keyframes btn-spark-rise {
0% { transform: translateY(0); }
85% { opacity: 0; }
100% { transform: translateY(-60px); opacity: 0; }
}
.btn-spark .btn-spark-pts i:nth-child(1) { left: 10%; animation-duration: 2.35s; animation-delay: .2s; }
.btn-spark .btn-spark-pts i:nth-child(2) { left: 30%; opacity: .7; animation-duration: 2.5s; animation-delay: .5s; }
.btn-spark .btn-spark-pts i:nth-child(3) { left: 25%; opacity: .8; animation-duration: 2.2s; animation-delay: .1s; }
.btn-spark .btn-spark-pts i:nth-child(4) { left: 44%; opacity: .6; animation-duration: 2.05s; }
.btn-spark .btn-spark-pts i:nth-child(5) { left: 50%; animation-duration: 1.9s; }
.btn-spark .btn-spark-pts i:nth-child(6) { left: 75%; opacity: .5; animation-duration: 1.5s; animation-delay: 1.5s; }
.btn-spark .btn-spark-pts i:nth-child(7) { left: 88%; opacity: .9; animation-duration: 2.2s; animation-delay: .2s; }
.btn-spark .btn-spark-pts i:nth-child(8) { left: 58%; opacity: .8; animation-duration: 2.25s; animation-delay: .2s; }
.btn-spark .btn-spark-pts i:nth-child(9) { left: 96%; opacity: .6; animation-duration: 2.6s; animation-delay: .1s; }
.btn-spark .btn-spark-pts i:nth-child(10) { left: 65%; animation-duration: 2.5s; animation-delay: .2s; }
.btn-spark .btn-spark-inner {
position: relative;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--on-accent);
font-weight: 700;
font-size: 15px;
letter-spacing: .06em;
}
.btn-spark .btn-spark-hex {
width: 18px;
height: 18px;
fill: transparent;
stroke: var(--on-accent);
stroke-width: 2.2;
stroke-linejoin: round;
}
.btn-spark:hover .btn-spark-hex,
.btn-spark:focus-visible .btn-spark-hex {
animation: btn-spark-dash 1s linear forwards, btn-spark-fill .1s linear forwards .95s;
}
@keyframes btn-spark-dash { from { stroke-dasharray: 0 0 0 0; } to { stroke-dasharray: 68 68 0 0; } }
@keyframes btn-spark-fill { to { fill: var(--on-accent); } }
/* Mobile new-post variant: square, no fold, the plus turns 90 degrees. */
.btn-spark.btn-spark-fab {
--spark-round: 16px;
min-width: 0;
width: 56px;
height: 56px;
padding: 0;
box-shadow: 0 12px 26px -10px color-mix(in srgb, var(--accent), transparent 15%);
}
.btn-spark.btn-spark-fab::after { display: none; }
.btn-spark.btn-spark-fab:active { transform: scale(.94); }
.btn-spark .btn-spark-plus {
position: relative;
z-index: 2;
width: 24px;
height: 24px;
color: var(--on-accent);
transition: transform .5s cubic-bezier(.2,.9,.3,1.4);
}
.btn-spark:hover .btn-spark-plus,
.btn-spark:focus-visible .btn-spark-plus { transform: rotate(90deg); }
/* Glossy crimson icon button: send comment, delete confirm, play. */
.btn-gloss {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border-radius: var(--r-ctl);
color: var(--on-accent);
background: linear-gradient(180deg, var(--accent-hi), var(--accent) 50%, var(--accent-lo));
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 32%, transparent),
inset 0 -1px 0 color-mix(in srgb, black 28%, transparent),
0 0 0 1px var(--accent-lo),
0 12px 26px -14px var(--accent);
transition: transform .18s var(--ease), box-shadow .3s;
}
.btn-gloss.btn-gloss-round { border-radius: 50%; }
.btn-gloss.btn-gloss-sm { width: 36px; height: 36px; }
.btn-gloss svg { width: 18px; height: 18px; color: var(--on-accent); }
.btn-gloss::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 50%;
background: linear-gradient(180deg, color-mix(in srgb, white 20%, transparent), transparent);
z-index: -1;
}
.btn-gloss::after {
content: "";
position: absolute;
top: -10%;
bottom: -10%;
width: 55%;
left: -90%;
background: linear-gradient(100deg, transparent, color-mix(in srgb, white 42%, transparent), transparent);
transform: skewX(-20deg);
transition: left .8s var(--ease);
}
.btn-gloss:hover::after,
.btn-gloss:focus-visible::after { left: 140%; }
.btn-gloss:active:not(:disabled) { transform: scale(.96); }
/* Disabled keeps a solid face (no opacity) so the icon stays readable. */
.btn-gloss:disabled {
opacity: 1;
cursor: not-allowed;
background: var(--tile-3);
color: var(--ink-2);
box-shadow: inset 0 0 0 1px var(--line-2);
}
.btn-gloss:disabled svg { color: var(--ink-2); }
.btn-gloss:disabled::before,
.btn-gloss:disabled::after { display: none; }
/* Reduced motion: hover and press states still change color, but nothing moves. */
@media (prefers-reduced-motion: reduce) {
.btn-flip,
.btn-flip *,
.btn-grow,
.btn-grow *,
.btn-sweep,
.btn-sweep::before,
.btn-depth,
.btn-depth *,
.btn-depth::before,
.btn-depth::after,
.btn-spark,
.btn-spark *,
.btn-spark::before,
.btn-spark::after,
.btn-gloss,
.btn-gloss::before,
.btn-gloss::after {
transition: none !important;
animation: none !important;
}
.btn-flip:active,
.btn-grow:active,
.btn-sweep:active,
.btn-spark:active,
.btn-gloss:active { transform: none !important; }
.btn-grow:hover .btn-grow-circle svg,
.btn-grow:focus-visible .btn-grow-circle svg,
.btn-spark:hover .btn-spark-plus,
.btn-spark:focus-visible .btn-spark-plus { transform: none !important; }
.btn-spark .btn-spark-pts { display: none; }
.btn-spark:hover .btn-spark-hex,
.btn-spark:focus-visible .btn-spark-hex { fill: var(--on-accent); }
.btn-gloss::after { display: none; }
}
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import path from "node:path";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import {
DepthButton,
FlipButton,
GrowButton,
IconButton,
SendIcon,
SparkleButton,
SweepButton,
flipLabels,
} from "@/components/Buttons";
import { ButtonGallery } from "@/components/ButtonGallery";
const html = (el: Parameters<typeof renderToStaticMarkup>[0]) => renderToStaticMarkup(el);
const css = readFileSync(path.resolve(__dirname, "../src/styles/buttons.css"), "utf8");
const globals = readFileSync(path.resolve(__dirname, "../src/app/globals.css"), "utf8");
/** Body of the first rule whose selector list matches exactly. */
function rule(selector: string): string {
const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const m = css.match(new RegExp(`(?:^|\\n|,)\\s*${esc}\\s*\\{([^}]*)\\}`));
if (!m) throw new Error(`no rule for ${selector}`);
return m[1];
}
describe("FlipButton states", () => {
it("ready: POST plus send icon, flips to PUBLISH with the icon", () => {
expect(flipLabels("ready")).toEqual({ front: "Post", back: "Publish", icon: true });
const out = html(createElement(FlipButton, { state: "ready" }));
expect(out).toContain('class="btn-flip"');
expect(out).not.toContain("disabled");
expect(out.match(/<svg/g)).toHaveLength(2);
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Publish/);
});
it("disabled: native disabled, flips to WRITE FIRST, no icon", () => {
const out = html(createElement(FlipButton, { state: "disabled" }));
expect(out).toContain('disabled=""');
expect(out).toMatch(/btn-flip-front">Post</);
expect(out).toMatch(/btn-flip-back" aria-hidden="true">Write first</);
expect(out).not.toContain("<svg");
});
it("posting: POSTING, busy, no flip panel", () => {
const out = html(createElement(FlipButton, { state: "posting" }));
expect(out).toContain("btn-flip btn-flip-busy");
expect(out).toContain('aria-busy="true"');
expect(out).toMatch(/btn-flip-front">Posting</);
expect(out).not.toContain("btn-flip-back");
});
it("the disabled face is solid graphite and never faded", () => {
const disabled = rule(".btn-flip:disabled");
expect(disabled).toMatch(/opacity:\s*1/);
expect(disabled).toMatch(/background:\s*var\(--tile-3\)/);
expect(disabled).toMatch(/color:\s*var\(--ink-2\)/);
expect(css).not.toMatch(/\.btn-flip[^{]*\{[^}]*opacity:\s*0?\.\d/);
});
it("sets text color explicitly for every state, never by inheritance", () => {
expect(rule(".btn-flip")).toMatch(/color:\s*var\(--bg\)/);
expect(rule(".btn-flip .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
expect(rule(".btn-flip .btn-flip-back")).toMatch(/color:\s*var\(--on-accent\)/);
expect(rule(".btn-flip:disabled .btn-flip-front")).toMatch(/color:\s*var\(--ink-2\)/);
expect(rule(".btn-flip:disabled .btn-flip-back")).toMatch(/color:\s*var\(--ink\)/);
expect(rule(".btn-flip.btn-flip-busy:disabled .btn-flip-front")).toMatch(/color:\s*var\(--bg\)/);
// Face and text never share a token in any state.
expect(rule(".btn-flip")).toMatch(/background:\s*var\(--ink\)/);
expect(rule(".btn-flip.btn-flip-busy:disabled")).toMatch(/background:\s*var\(--ink\)/);
});
});
describe("the other styles", () => {
it("GrowButton renders the crimson circle and the label", () => {
const out = html(createElement(GrowButton, { label: "3 new posts" }));
expect(out).toContain('class="btn-grow"');
expect(out).toContain("btn-grow-circle");
expect(out).toMatch(/btn-grow-text">3 new posts</);
});
it("SweepButton stays filled and pressed when selected", () => {
expect(html(createElement(SweepButton, { label: "Business", selected: true }))).toContain('class="btn-sweep btn-sweep-on" aria-pressed="true"');
expect(html(createElement(SweepButton, { label: "Media", selected: false }))).toContain('aria-pressed="false"');
expect(html(createElement(SweepButton, { label: "Cancel" }))).not.toContain("aria-pressed");
});
it("DepthButton renders label, sublabel and icon", () => {
const out = html(createElement(DepthButton, { label: "Trending", sublabel: "Hot now", icon: createElement(SendIcon) }));
expect(out).toContain("btn-depth-icon");
expect(out).toMatch(/btn-depth-label">Trending</);
expect(out).toMatch(/btn-depth-sub">Hot now</);
});
it("SparkleButton: sign in has the fold, hexagon and ten sparks; fab has a plus and a name", () => {
const signin = html(createElement(SparkleButton, { label: "Sign in", type: "submit" }));
expect(signin).toContain("btn-spark-fold");
expect(signin).toContain("btn-spark-hex");
expect(signin.match(/<i><\/i>/g)).toHaveLength(10);
expect(signin).toContain('type="submit"');
const fab = html(createElement(SparkleButton, { variant: "fab", label: "New post" }));
expect(fab).toContain('aria-label="New post"');
expect(fab).toContain("btn-spark btn-spark-fab");
expect(fab).not.toContain("btn-spark-fold");
expect(fab).toContain("btn-spark-plus");
});
it("IconButton is the glossy crimson button with an accessible name", () => {
const out = html(createElement(IconButton, { label: "Send comment", icon: createElement(SendIcon), shape: "round", size: "sm" }));
expect(out).toContain('class="btn-gloss btn-gloss-round btn-gloss-sm"');
expect(out).toContain('aria-label="Send comment"');
expect(rule(".btn-gloss:disabled")).toMatch(/opacity:\s*1/);
});
});
describe("button CSS rules", () => {
it("uses only .btn-* class selectors", () => {
const selectors = css.replace(/\/\*[\s\S]*?\*\//g, "").match(/\.[a-zA-Z][\w-]*/g) ?? [];
expect(selectors.filter((s) => !s.startsWith(".btn-"))).toEqual([]);
});
it("does not include the rejected liquid waves or 3D drop buttons", () => {
expect(css).not.toMatch(/wave|wv/i);
});
it("stops hover and press motion under prefers-reduced-motion", () => {
const block = css.slice(css.indexOf("@media (prefers-reduced-motion: reduce)"));
expect(block.length).toBeGreaterThan(0);
for (const cls of ["btn-flip", "btn-grow", "btn-sweep", "btn-depth", "btn-spark", "btn-gloss"]) {
expect(block).toContain(`.${cls}`);
}
expect(block).toMatch(/transition:\s*none !important/);
expect(block).toMatch(/animation:\s*none !important/);
expect(block).toMatch(/transform:\s*none !important/);
});
it("every theme defines the button tokens", () => {
for (const token of ["--shadow-btn", "--shadow-float", "--neu-d", "--neu-l"]) {
expect(globals.match(new RegExp(`${token}:`, "g"))).toHaveLength(2);
}
expect(globals).toContain("--on-accent:");
});
});
describe("ButtonGallery story", () => {
it("shows every style in both themes", () => {
for (const theme of ["dark", "light"] as const) {
const out = html(createElement(ButtonGallery, { theme }));
expect(out).toContain(`data-theme="${theme}"`);
for (const cls of ["btn-flip", "btn-flip-busy", "btn-grow", "btn-sweep-on", "btn-depth", "btn-spark-fold", "btn-spark-fab", "btn-gloss"]) {
expect(out).toContain(cls);
}
expect(out).toMatch(/Write first/);
expect(out).toMatch(/Posting/);
}
});
});