Promote to main: Feed browser luxury redesign #7
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user