From dd47cfd9118b764fff4a201c741dae17b306b654 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 21:55:26 -0600 Subject: [PATCH] jhxk68cdy: Add the five approved button styles, the glossy icon button and a dev story page --- src/app/dev/buttons/page.tsx | 19 ++ src/app/globals.css | 23 +- src/app/layout.tsx | 1 + src/components/ButtonGallery.tsx | 79 +++++ src/components/Buttons.tsx | 239 +++++++++++++++ src/styles/buttons.css | 492 +++++++++++++++++++++++++++++++ tests/buttons.test.ts | 160 ++++++++++ 7 files changed, 1010 insertions(+), 3 deletions(-) create mode 100644 src/app/dev/buttons/page.tsx create mode 100644 src/components/ButtonGallery.tsx create mode 100644 src/components/Buttons.tsx create mode 100644 src/styles/buttons.css create mode 100644 tests/buttons.test.ts diff --git a/src/app/dev/buttons/page.tsx b/src/app/dev/buttons/page.tsx new file mode 100644 index 0000000..5c63d78 --- /dev/null +++ b/src/app/dev/buttons/page.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 6a8e0bf..55ebebe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,9 +1,11 @@ /* Global styles only (ADR 0001, section 6): layer order, tokens, reset, focus ring. */ @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[data-theme="dark"] { +:root[data-theme="dark"], +[data-theme="dark"] { --bg: #09090B; --tile: #121215; --tile-2: #19191D; @@ -17,10 +19,16 @@ --danger: #FF6B70; --glass: rgba(18,18,21,.72); --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; } -:root[data-theme="light"] { +:root[data-theme="light"], +[data-theme="light"] { --bg: #EFEDE9; --tile: #FBFAF8; --tile-2: #F3F1EE; @@ -34,6 +42,10 @@ --danger: #B3121A; --glass: rgba(251,250,248,.74); --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; } @@ -42,6 +54,11 @@ --accent-hi: color-mix(in srgb, var(--accent), white 26%); --accent-lo: color-mix(in srgb, var(--accent), black 30%); --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. */ --hex-oxblood: linear-gradient(145deg, #6e1a22, #3a0d12); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8b6545..e5b3f64 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import { Cormorant_Garamond, Manrope } from "next/font/google"; import { DEFAULT_THEME, THEME_SCRIPT } from "@/lib/theme"; import "./globals.css"; import "../styles/type.css"; +import "../styles/buttons.css"; import "../styles/avatar.css"; import "../styles/legacy.css"; diff --git a/src/components/ButtonGallery.tsx b/src/components/ButtonGallery.tsx new file mode 100644 index 0000000..dc7f109 --- /dev/null +++ b/src/components/ButtonGallery.tsx @@ -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 ( +
+

{title}

+
{children}
+
+ ); +} + +const FlameIcon = ( + + + +); +const TrashIcon = ( + +); +const PlayIcon = ( + +); + +export function ButtonGallery({ theme }: { theme: Theme }) { + return ( +
+

+ {theme === "dark" ? "Dark" : "Light"} theme +

+
+ + + +
+
+ +
+
+ + + +
+
+
+ +
+
+
+ + +
+
+ } /> + + + } disabled /> +
+
+ ); +} diff --git a/src/components/Buttons.tsx b/src/components/Buttons.tsx new file mode 100644 index 0000000..8ce2346 --- /dev/null +++ b/src/components/Buttons.tsx @@ -0,0 +1,239 @@ +// Button system (spec "Buttons", ADR 0001). Styles live in src/styles/buttons.css. +// Each component renders a native + ); +} + +/* 2. Circle expand ----------------------------------------------------- */ + +export interface GrowButtonProps extends NativeButton { + label: string; + icon?: ReactNode; +} + +export function GrowButton({ label, icon, className, type = "button", ...rest }: GrowButtonProps) { + return ( + + ); +} + +/* 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 ( + + ); +} + +/* 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 ( + + ); +} + +/* 5. Sparkle glow ------------------------------------------------------ */ + +const SPARKS = Array.from({ length: 10 }, (_, i) => i); + +function Sparks() { + return ( + + ); +} + +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 ( + + ); + } + return ( + + ); +} + +/* 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 ( + + ); +} diff --git a/src/styles/buttons.css b/src/styles/buttons.css new file mode 100644 index 0000000..7fce46a --- /dev/null +++ b/src/styles/buttons.css @@ -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; } +} diff --git a/tests/buttons.test.ts b/tests/buttons.test.ts new file mode 100644 index 0000000..4575047 --- /dev/null +++ b/tests/buttons.test.ts @@ -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[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(/<\/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/); + } + }); +});