veza/apps/web/src/components/sumi/SumiButton.tsx
senke dfeff836ce feat(ui): add SUMI design system components, seasonal hooks, and i18n updates
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>
2026-03-31 19:15:54 +02:00

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';