Add SumiButton and SumiCanvas components with lavis ink wash aesthetic. Add useSeason and useTimeOfDay hooks for time-aware UI tinting. Update storybook config, UI components, locales (en/es/fr), and dependencies. Add Chromatic CI workflow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
136 lines
5 KiB
TypeScript
136 lines
5 KiB
TypeScript
/**
|
|
* SumiButton — A button that feels like it was brushed into existence
|
|
*
|
|
* Design principles:
|
|
* - No hard borders. Edges dissolve like ink meeting water.
|
|
* - Hover = ink spreading subtly outward (bokashi effect)
|
|
* - Press = ink concentrating inward (darker, denser)
|
|
* - Focus = a single confident brush stroke appears around it
|
|
* - The button floats — slight elevation, breathing shadow
|
|
*
|
|
* Despite the ethereal aesthetic, contrast ratios meet WCAG AAA.
|
|
*/
|
|
import React, { forwardRef, type ButtonHTMLAttributes } from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface SumiButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
/** Visual weight */
|
|
variant?: 'ink' | 'ghost' | 'wash' | 'seal';
|
|
/** Size */
|
|
size?: 'sm' | 'md' | 'lg';
|
|
/** Loading state — ink pulses gently */
|
|
loading?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Variants:
|
|
*
|
|
* - `ink` — Primary. Solid ink fill, white text. The confident brush stroke.
|
|
* - `ghost` — Secondary. Transparent until hovered, then ink bleeds in.
|
|
* - `wash` — Tertiary. Diluted ink wash background, like watered-down sumi.
|
|
* - `seal` — Accent. Vermillion hanko seal style — small, decisive.
|
|
*/
|
|
export const SumiButton = forwardRef<HTMLButtonElement, SumiButtonProps>(
|
|
({ variant = 'ink', size = 'md', loading = false, className, children, disabled, ...props }, ref) => {
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
disabled={disabled || loading}
|
|
className={cn(
|
|
// Base — floating, organic
|
|
'sumi-button',
|
|
'relative inline-flex items-center justify-center',
|
|
'font-medium tracking-wide',
|
|
'transition-all duration-300',
|
|
'outline-none',
|
|
|
|
// No hard borders, organic edges
|
|
'border-0',
|
|
|
|
// Focus: brush stroke ring
|
|
'focus-visible:ring-2 focus-visible:ring-offset-2',
|
|
'focus-visible:ring-[var(--sumi-accent)]',
|
|
'focus-visible:ring-offset-[var(--sumi-bg-base)]',
|
|
|
|
// Disabled
|
|
'disabled:pointer-events-none disabled:opacity-30',
|
|
|
|
// Size
|
|
size === 'sm' && 'h-8 px-4 text-xs rounded-sm gap-1.5',
|
|
size === 'md' && 'h-10 px-6 text-sm rounded gap-2',
|
|
size === 'lg' && 'h-12 px-8 text-base rounded gap-2.5',
|
|
|
|
// Variant styles
|
|
variant === 'ink' && [
|
|
'bg-[var(--sumi-text-primary)] text-[var(--sumi-bg-base)]',
|
|
// Ink diffusion shadow — not a drop shadow, more like ink bleeding into paper
|
|
'shadow-[0_2px_12px_-2px_rgba(232,224,208,0.15)]',
|
|
// Hover: ink spreads, shadow grows like a puddle
|
|
'hover:shadow-[0_4px_24px_-4px_rgba(232,224,208,0.25)]',
|
|
'hover:scale-[1.02]',
|
|
// Active: ink concentrates, button sinks
|
|
'active:scale-[0.98]',
|
|
'active:shadow-[0_1px_4px_-1px_rgba(232,224,208,0.1)]',
|
|
],
|
|
|
|
variant === 'ghost' && [
|
|
'bg-transparent text-[var(--sumi-text-primary)]',
|
|
// Subtle ink bleed on hover
|
|
'hover:bg-[var(--sumi-bg-hover)]',
|
|
'hover:shadow-[0_0_20px_-4px_rgba(232,224,208,0.05)]',
|
|
'active:bg-[var(--sumi-bg-active)]',
|
|
],
|
|
|
|
variant === 'wash' && [
|
|
// Diluted ink — like a watercolor wash
|
|
'bg-[var(--sumi-bg-wash)] text-[var(--sumi-text-primary)]',
|
|
'shadow-[0_1px_8px_-2px_rgba(13,13,11,0.3)]',
|
|
'hover:bg-[var(--sumi-bg-raised)]',
|
|
'hover:shadow-[0_2px_16px_-4px_rgba(13,13,11,0.4)]',
|
|
'hover:scale-[1.01]',
|
|
'active:scale-[0.99]',
|
|
'active:bg-[var(--sumi-bg-active)]',
|
|
],
|
|
|
|
variant === 'seal' && [
|
|
// Vermillion hanko seal — decisive and bold
|
|
'bg-[var(--sumi-accent)] text-[var(--sumi-text-inverse)]',
|
|
'shadow-[0_2px_12px_-2px_rgba(184,58,30,0.3)]',
|
|
'hover:bg-[var(--sumi-accent-hover)]',
|
|
'hover:shadow-[0_4px_20px_-4px_rgba(184,58,30,0.4)]',
|
|
'hover:scale-[1.02]',
|
|
'active:bg-[var(--sumi-accent-active)]',
|
|
'active:scale-[0.98]',
|
|
],
|
|
|
|
// Loading pulse
|
|
loading && 'animate-pulse',
|
|
|
|
className,
|
|
)}
|
|
{...props}
|
|
>
|
|
{/* Ink bleed effect on hover — expands behind the button */}
|
|
<span
|
|
className={cn(
|
|
'absolute inset-0 rounded-[inherit] opacity-0 transition-opacity duration-500',
|
|
'pointer-events-none',
|
|
variant === 'ink' && 'bg-[var(--sumi-text-primary)] blur-md group-hover:opacity-10',
|
|
variant === 'seal' && 'bg-[var(--sumi-accent)] blur-md group-hover:opacity-15',
|
|
)}
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
{/* Content */}
|
|
<span className="relative z-10 flex items-center gap-[inherit]">
|
|
{loading && (
|
|
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
)}
|
|
{children}
|
|
</span>
|
|
</button>
|
|
);
|
|
},
|
|
);
|
|
|
|
SumiButton.displayName = 'SumiButton';
|