diff --git a/.github/workflows/chromatic.yml b/.github/workflows/chromatic.yml new file mode 100644 index 000000000..2220dd42e --- /dev/null +++ b/.github/workflows/chromatic.yml @@ -0,0 +1,54 @@ +# Chromatic: visual regression testing for Storybook. +# Runs on every push/PR that touches the web app. +# Requires CHROMATIC_PROJECT_TOKEN secret in GitHub repo settings. +name: Chromatic + +on: + push: + branches: [main, develop] + paths: + - "apps/web/**" + - ".github/workflows/chromatic.yml" + pull_request: + paths: + - "apps/web/**" + - ".github/workflows/chromatic.yml" + workflow_dispatch: + +jobs: + chromatic: + name: Visual regression tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 # Required for Chromatic to detect changes + + - name: Set up Node + uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + working-directory: apps/web + + - name: Run Chromatic + uses: chromaui/action@latest + with: + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + workingDir: apps/web + buildScriptName: build-storybook + exitZeroOnChanges: true # Don't fail PR on visual changes, just flag them + exitOnceUploaded: true # Speed up CI — don't wait for full processing + onlyChanged: true # Only snapshot stories affected by changes + externals: | + apps/web/src/**/*.css + apps/web/public/** + env: + VITE_API_URL: /api/v1 + VITE_USE_MSW: "true" + VITE_STORYBOOK: "true" diff --git a/apps/web/.storybook/decorators.tsx b/apps/web/.storybook/decorators.tsx index 87e580e70..c1369d808 100644 --- a/apps/web/.storybook/decorators.tsx +++ b/apps/web/.storybook/decorators.tsx @@ -29,7 +29,8 @@ const queryClient = new QueryClient({ }); export const StorybookDecorator: Decorator = (Story, context) => { - const isDark = context.globals?.backgrounds?.value !== '#ffffff'; + const bgValue = context.globals?.backgrounds?.value; + const isDark = bgValue !== 'light'; // only 'light' triggers light mode, everything else = dark const initialEntries = (context.parameters?.router as { initialEntries?: string[] } | undefined)?.initialEntries ?? ['/']; diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts index 9d89eb9dd..ccd70f0b4 100644 --- a/apps/web/.storybook/main.ts +++ b/apps/web/.storybook/main.ts @@ -1,7 +1,11 @@ +// This file has been automatically migrated to valid ESM format by Storybook. +import { createRequire } from "node:module"; import type { StorybookConfig } from '@storybook/react-vite'; import { dirname, join } from "path" +const require = createRequire(import.meta.url); + function getAbsolutePath(value: string) { return dirname(require.resolve(join(value, "package.json"))) } @@ -12,16 +16,14 @@ const config: StorybookConfig = { "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" ], "addons": [ - getAbsolutePath('@storybook/addon-essentials'), getAbsolutePath('@storybook/addon-a11y'), - getAbsolutePath('@storybook/addon-interactions'), - getAbsolutePath('msw-storybook-addon'), + getAbsolutePath("@storybook/addon-docs"), + getAbsolutePath("@storybook/addon-mcp"), ], "staticDirs": ['../public'], "framework": getAbsolutePath('@storybook/react-vite'), "docs": { - "defaultName": "Documentation", - "autodocs": true + defaultName: "Documentation" }, "typescript": { "reactDocgen": "react-docgen-typescript", diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx index 91dee570c..1e7919e3c 100644 --- a/apps/web/.storybook/preview.tsx +++ b/apps/web/.storybook/preview.tsx @@ -56,27 +56,33 @@ const preview: Preview = { expanded: true, }, a11y: { - test: 'todo', + test: 'error-on-violation', }, viewport: { - viewports: customViewports, + options: customViewports, }, backgrounds: { - default: 'dark', - values: [ - { name: 'dark', value: '#121215' }, - { name: 'light', value: '#faf9f6' }, - { name: 'raised', value: '#1a1a1f' }, - ], + options: { + dark: { name: 'dark', value: '#121215' }, + light: { name: 'light', value: '#faf9f6' }, + raised: { name: 'raised', value: '#1a1a1f' } + } }, layout: 'centered', docs: { toc: true, // Enable table of contents in docs }, }, + decorators: [StorybookDecorator], tags: ['autodocs'], loaders: [mswLoader], + + initialGlobals: { + backgrounds: { + value: 'dark' + } + } }; export default preview; diff --git a/apps/web/package.json b/apps/web/package.json index a212ae0f9..1935f686b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,7 +57,9 @@ "build-storybook": "cross-env VITE_API_URL=/api/v1 VITE_USE_MSW=true VITE_STORYBOOK=true storybook build", "test:storybook": "node scripts/audit-storybook.js", "test:storybook:playwright": "echo 'Storybook tests moved to repo root: npm run e2e -- --grep storybook'", - "validate:storybook": "node scripts/validate-storybook.cjs" + "validate:storybook": "node scripts/validate-storybook.cjs", + "chromatic": "chromatic --project-token=${CHROMATIC_PROJECT_TOKEN}", + "test:storybook:vitest": "vitest --project storybook" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -69,6 +71,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", "@sentry/react": "^10.32.1", + "@storybook/addon-mcp": "^0.4.2", "@tanstack/react-query": "^5.17.0", "@tanstack/react-virtual": "^3.13.12", "axios": "^1.13.5", @@ -97,11 +100,12 @@ }, "devDependencies": { "@openapitools/openapi-generator-cli": "^2.27.0", - "@storybook/addon-a11y": "^8.6.15", - "@storybook/addon-essentials": "^8.6.15", - "@storybook/addon-interactions": "^8.6.15", - "@storybook/builder-vite": "^8.6.15", - "@storybook/react-vite": "^8.6.15", + "@storybook/addon-a11y": "^10.3.3", + "@storybook/addon-docs": "^10.3.3", + "@storybook/addon-vitest": "^10.3.3", + "@storybook/blocks": "^8.6.14", + "@storybook/builder-vite": "^10.3.3", + "@storybook/react-vite": "^10.3.3", "@tailwindcss/postcss": "^4.0.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.2.1", @@ -128,6 +132,7 @@ "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.5", + "eslint-plugin-storybook": "10.3.3", "husky": "^9.1.7", "jsdom": "^24.0.0", "msw": "^2.11.2", @@ -136,7 +141,7 @@ "pngjs": "^7.0.0", "prettier": "^3.2.5", "rollup-plugin-visualizer": "^6.0.5", - "storybook": "^8.6.15", + "storybook": "^10.3.3", "storybook-dark-mode": "^4.0.2", "swagger-ui-dist": "^5.31.0", "swagger-ui-react": "^5.31.0", diff --git a/apps/web/src/components/demo/DesignSystemDemo.tsx b/apps/web/src/components/demo/DesignSystemDemo.tsx index b9c6ddb5b..b7307a7db 100644 --- a/apps/web/src/components/demo/DesignSystemDemo.tsx +++ b/apps/web/src/components/demo/DesignSystemDemo.tsx @@ -1,11 +1,57 @@ -import React from 'react'; +import React, { useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from '@/hooks/useTranslation'; export const DesignSystemDemo: React.FC = () => { + const { t } = useTranslation(); + + useEffect(() => { + document.title = t('designSystem.pageTitle'); + }, [t]); + return ( -
-

Design System Demo

-

Component under construction.

-
+
+
+

+ {t('designSystem.title')} +

+
+ +
+

+ {t('designSystem.underConstruction')} +

+
+ + {t('designSystem.backToHome')} + +
+
+
); }; diff --git a/apps/web/src/components/feedback/AnnouncementBanner.tsx b/apps/web/src/components/feedback/AnnouncementBanner.tsx index 8bf4102c5..764fda797 100644 --- a/apps/web/src/components/feedback/AnnouncementBanner.tsx +++ b/apps/web/src/components/feedback/AnnouncementBanner.tsx @@ -38,6 +38,7 @@ const typeConfig: Record export function AnnouncementBanner() { const [announcements, setAnnouncements] = useState([]); const [dismissed, setDismissed] = useState>(loadDismissed); + const [showAll, setShowAll] = useState(false); useEffect(() => { adminService.getActiveAnnouncements().then(setAnnouncements).catch(() => {}); @@ -54,9 +55,8 @@ export function AnnouncementBanner() { const visible = announcements.filter((a) => !dismissed.has(a.id)); if (visible.length === 0) return null; - // Show max 1 announcement at a time to prevent content being pushed below fold - const shown = visible.slice(0, 1); - const remaining = visible.length - shown.length; + const shown = showAll ? visible : visible.slice(0, 1); + const remaining = showAll ? 0 : visible.length - shown.length; return (
@@ -90,9 +90,14 @@ export function AnnouncementBanner() { ); })} {remaining > 0 && ( -

+ )}

); diff --git a/apps/web/src/components/feedback/Toast.stories.tsx b/apps/web/src/components/feedback/Toast.stories.tsx index a8742b29f..bf991fe85 100644 --- a/apps/web/src/components/feedback/Toast.stories.tsx +++ b/apps/web/src/components/feedback/Toast.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { fn } from '@storybook/test'; +import { fn } from 'storybook/test'; import { ToastComponent } from './Toast'; const mockToast = { diff --git a/apps/web/src/components/layout/Header.tsx b/apps/web/src/components/layout/Header.tsx index 81217597b..4c4db7a0e 100644 --- a/apps/web/src/components/layout/Header.tsx +++ b/apps/web/src/components/layout/Header.tsx @@ -135,6 +135,7 @@ export function Header(_props: HeaderProps) { variant="ghost" size="icon" onClick={toggleTheme} + aria-label={t('common.changeTheme')} className="min-h-10 min-w-10 rounded-full hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors duration-[var(--duration-fast)]" > {getThemeIcon()} diff --git a/apps/web/src/components/library/playlists/QueueSortableItem.tsx b/apps/web/src/components/library/playlists/QueueSortableItem.tsx index 67c7c88cd..89a5332ab 100644 --- a/apps/web/src/components/library/playlists/QueueSortableItem.tsx +++ b/apps/web/src/components/library/playlists/QueueSortableItem.tsx @@ -7,6 +7,7 @@ import { CSS } from '@dnd-kit/utilities'; import { cn } from '@/lib/utils'; import { GripVertical, Play, X } from 'lucide-react'; import { formatTime } from '@/features/player/services/playerService'; +import { useTranslation } from '@/hooks/useTranslation'; import type { Track } from '@/features/player/types'; interface QueueSortableItemProps { @@ -22,6 +23,7 @@ export function QueueSortableItem({ onPlay, onRemove, }: QueueSortableItemProps) { + const { t } = useTranslation(); const { attributes, listeners, @@ -50,6 +52,7 @@ export function QueueSortableItem({
@@ -57,15 +60,17 @@ export function QueueSortableItem({
-
onPlay(track)} + aria-label={t('queue.playTrack', { title: track.title })} > -
+
@@ -82,7 +87,7 @@ export function QueueSortableItem({ type="button" className="p-2 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity" onClick={onRemove} - aria-label="Remove from queue" + aria-label={t('queue.removeFromQueue')} > diff --git a/apps/web/src/components/library/playlists/QueueView.tsx b/apps/web/src/components/library/playlists/QueueView.tsx index c3a7851a1..d549efdbc 100644 --- a/apps/web/src/components/library/playlists/QueueView.tsx +++ b/apps/web/src/components/library/playlists/QueueView.tsx @@ -34,8 +34,10 @@ import { } from 'lucide-react'; import { useToast } from '../../../components/feedback/ToastProvider'; import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'; +import { useTranslation } from '@/hooks/useTranslation'; export const QueueView: React.FC = () => { + const { t } = useTranslation(); const { queue, currentTrack, @@ -53,6 +55,8 @@ export const QueueView: React.FC = () => { const [showSaveModal, setShowSaveModal] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); + const isQueueEmpty = !currentTrack && upNext.length === 0; + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { @@ -84,7 +88,7 @@ export const QueueView: React.FC = () => { ...upNext, ]; if (tracksToSave.length === 0) { - addToast('Queue is empty', 'error'); + addToast(t('queue.emptyQueueError'), 'error'); throw new Error('Queue is empty'); } const playlist = await createPlaylist({ @@ -95,18 +99,18 @@ export const QueueView: React.FC = () => { for (const track of tracksToSave) { await addTrack(playlist.id, String(track.id)); } - addToast(`Queue saved as "${name}"`, 'success'); + addToast(t('queue.savedAs', { name }), 'success'); }; return ( -
+

- PLAY QUEUE + {t('queue.heading')}

- {upNext.length} tracks upcoming + {t('queue.tracksUpcoming', { count: upNext.length })}

@@ -114,16 +118,18 @@ export const QueueView: React.FC = () => { variant="ghost" onClick={() => setShowSaveModal(true)} icon={} + disabled={isQueueEmpty} > - Save Queue + {t('queue.saveQueue')}
@@ -132,19 +138,25 @@ export const QueueView: React.FC = () => { {currentTrack && (

- Now Playing + {t('queue.nowPlaying')}

-
@@ -161,7 +173,7 @@ export const QueueView: React.FC = () => {
)} -
+

{currentTrack.title} @@ -178,7 +190,7 @@ export const QueueView: React.FC = () => { {/* Up Next */}

- Up Next + {t('queue.upNext')}

@@ -186,8 +198,8 @@ export const QueueView: React.FC = () => { } - title="Nothing in your queue" - description="Start playing music and add tracks to build your queue." + title={t('queue.emptyTitle')} + description={t('queue.emptyDescription')} size="md" /> ) : ( @@ -215,12 +227,11 @@ export const QueueView: React.FC = () => {
- {showSaveModal && ( - setShowSaveModal(false)} - onSave={handleSavePlaylist} - /> - )} + setShowSaveModal(false)} + onSave={handleSavePlaylist} + /> { clearQueue(); setShowClearConfirm(false); }} - title="Clear queue" - description="Remove all tracks from your queue. This cannot be undone." - confirmLabel="Clear" + title={t('queue.clearTitle')} + description={t('queue.clearDescription')} + confirmLabel={t('queue.clearConfirm')} variant="destructive" />

diff --git a/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.stories.tsx b/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.stories.tsx index c613c6dbb..d789b76ad 100644 --- a/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.stories.tsx +++ b/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { fn } from '@storybook/test'; +import { fn } from 'storybook/test'; import { SaveQueueAsPlaylistModal } from './SaveQueueAsPlaylistModal'; /** @@ -21,6 +21,7 @@ const meta: Meta = { }, tags: ['autodocs'], args: { + open: true, onClose: fn(), onSave: fn(), }, diff --git a/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.tsx b/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.tsx index b9c3498f0..66253ff8a 100644 --- a/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.tsx +++ b/apps/web/src/components/library/playlists/SaveQueueAsPlaylistModal.tsx @@ -1,25 +1,28 @@ import React, { useState } from 'react'; -import { Button } from '../../ui/button'; +import { Dialog } from '../../ui/dialog'; import { Input } from '../../ui/input'; -import { X, Lock, Globe } from 'lucide-react'; +import { Lock, Globe } from 'lucide-react'; import { useToast } from '../../../components/feedback/ToastProvider'; +import { useTranslation } from '@/hooks/useTranslation'; interface SaveQueueAsPlaylistModalProps { + open: boolean; onClose: () => void; onSave: (name: string, isPublic: boolean) => void | Promise; } export const SaveQueueAsPlaylistModal: React.FC< SaveQueueAsPlaylistModalProps -> = ({ onClose, onSave }) => { +> = ({ open, onClose, onSave }) => { const { addToast } = useToast(); + const { t } = useTranslation(); const [name, setName] = useState(''); const [isPublic, setIsPublic] = useState(false); - const [saving, setSaving] = useState(false); + const handleSubmit = async () => { if (!name) { - addToast('Please name your playlist', 'error'); + addToast(t('queue.saveAsPlaylist.nameRequired'), 'error'); return; } setSaving(true); @@ -28,7 +31,9 @@ export const SaveQueueAsPlaylistModal: React.FC< onClose(); } catch (err) { addToast( - err instanceof Error ? err.message : 'Failed to save playlist', + err instanceof Error + ? err.message + : t('queue.saveAsPlaylist.saveFailed'), 'error', ); } finally { @@ -37,71 +42,63 @@ export const SaveQueueAsPlaylistModal: React.FC< }; return ( -
-
-
-
-

Save Queue as Playlist

- -
+ +
+ setName(e.target.value)} + autoFocus + placeholder={t('queue.saveAsPlaylist.namePlaceholder')} + /> -
- setName(e.target.value)} - autoFocus - placeholder="My Queue Session" - /> - -
setIsPublic(!isPublic)} - > -
- {isPublic ? ( - - ) : ( - - )} -
-
- {isPublic ? 'Public Playlist' : 'Private Playlist'} -
-
- {isPublic ? 'Visible on your profile' : 'Only visible to you'} -
+
- -
- - -
+
+
+
-
+
); }; diff --git a/apps/web/src/components/player/FullPlayer.stories.tsx b/apps/web/src/components/player/FullPlayer.stories.tsx index 0fb3f7dd4..eeec01615 100644 --- a/apps/web/src/components/player/FullPlayer.stories.tsx +++ b/apps/web/src/components/player/FullPlayer.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; -import { fn } from '@storybook/test'; +import { fn } from 'storybook/test'; import { FullPlayer } from './FullPlayer'; /** diff --git a/apps/web/src/components/sumi/SumiButton.stories.tsx b/apps/web/src/components/sumi/SumiButton.stories.tsx new file mode 100644 index 000000000..3e14b4695 --- /dev/null +++ b/apps/web/src/components/sumi/SumiButton.stories.tsx @@ -0,0 +1,124 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SumiButton } from './SumiButton'; +import { SumiCanvas } from './SumiCanvas'; + +const meta: Meta = { + title: 'Design System/SumiButton', + component: SumiButton, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + variant: { + control: 'select', + options: ['ink', 'ghost', 'wash', 'seal'], + }, + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + }, + }, + args: { + children: 'Button', + variant: 'ink', + size: 'md', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const AllVariants: Story = { + render: () => ( +
+
+ Ink + Wash + Seal + Ghost +
+
+ Small + Medium + Large +
+
+ ), +}; + +export const Ink: Story = { + args: { variant: 'ink', children: 'Listen Now' }, +}; + +export const Ghost: Story = { + args: { variant: 'ghost', children: 'Skip' }, +}; + +export const Wash: Story = { + args: { variant: 'wash', children: 'Explore' }, +}; + +export const Seal: Story = { + args: { variant: 'seal', children: 'Create' }, +}; + +export const Loading: Story = { + args: { loading: true, children: 'Uploading...' }, +}; + +export const Disabled: Story = { + args: { disabled: true, children: 'Unavailable' }, +}; + +export const OnCanvas: Story = { + parameters: { layout: 'fullscreen', backgrounds: { disable: true } }, + render: () => ( + +
+

+ Buttons on the living canvas +

+
+ Play Album + Save + Share +
+
+ Previous + Next +
+
+
+ ), +}; + +export const VariantsOnAllPeriods: Story = { + parameters: { layout: 'fullscreen', backgrounds: { disable: true } }, + render: () => ( +
+ {(['dawn', 'morning', 'midday', 'afternoon', 'dusk', 'night'] as const).map((period) => ( + +
+ + {period} + +
+ Ink + Seal + Wash +
+
+
+ ))} +
+ ), +}; diff --git a/apps/web/src/components/sumi/SumiButton.tsx b/apps/web/src/components/sumi/SumiButton.tsx new file mode 100644 index 000000000..9ff6c45e2 --- /dev/null +++ b/apps/web/src/components/sumi/SumiButton.tsx @@ -0,0 +1,136 @@ +/** + * 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 { + /** 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( + ({ variant = 'ink', size = 'md', loading = false, className, children, disabled, ...props }, ref) => { + return ( + + ); + }, +); + +SumiButton.displayName = 'SumiButton'; diff --git a/apps/web/src/components/sumi/SumiCanvas.stories.tsx b/apps/web/src/components/sumi/SumiCanvas.stories.tsx new file mode 100644 index 000000000..6ecfabaed --- /dev/null +++ b/apps/web/src/components/sumi/SumiCanvas.stories.tsx @@ -0,0 +1,176 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { SumiCanvas } from './SumiCanvas'; +import { SumiButton } from './SumiButton'; + +const meta: Meta = { + title: 'Design System/SumiCanvas', + component: SumiCanvas, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + backgrounds: { disable: true }, + }, + argTypes: { + timeOfDay: { + control: 'select', + options: ['dawn', 'morning', 'midday', 'afternoon', 'dusk', 'night'], + }, + season: { + control: 'select', + options: ['spring', 'summer', 'autumn', 'winter'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +const DemoContent = () => ( +
+

+ 墨 SUMI +

+

+ The interface breathes with the day. Light shifts, ink flows, seasons turn. +

+
+ Listen + Explore + Create + About +
+
+); + +export const Dawn: Story = { + args: { + timeOfDay: 'dawn', + season: 'spring', + children: undefined, + }, + render: (args) => ( + + + + ), +}; + +export const Morning: Story = { + args: { timeOfDay: 'morning', season: 'spring' }, + render: (args) => ( + + + + ), +}; + +export const Midday: Story = { + args: { timeOfDay: 'midday', season: 'summer' }, + render: (args) => ( + + + + ), +}; + +export const Afternoon: Story = { + args: { timeOfDay: 'afternoon', season: 'autumn' }, + render: (args) => ( + + + + ), +}; + +export const Dusk: Story = { + args: { timeOfDay: 'dusk', season: 'autumn' }, + render: (args) => ( + + + + ), +}; + +export const Night: Story = { + args: { timeOfDay: 'night', season: 'winter' }, + render: (args) => ( + + + + ), +}; + +export const AllPeriods: Story = { + parameters: { layout: 'fullscreen' }, + render: () => ( +
+ {(['dawn', 'morning', 'midday', 'afternoon', 'dusk', 'night'] as const).map((period) => ( + +
+ + {period} + + {period} +
+
+ ))} +
+ ), +}; + +export const AllSeasons: Story = { + parameters: { layout: 'fullscreen' }, + render: () => ( +
+ {(['spring', 'summer', 'autumn', 'winter'] as const).map((season) => ( + +
+ + {season === 'spring' ? '春' : season === 'summer' ? '夏' : season === 'autumn' ? '秋' : '冬'} + + + {season} + +
+
+ ))} +
+ ), +}; + +export const Minimal: Story = { + args: { minimal: true, timeOfDay: 'dusk' }, + render: (args) => ( + +
+

+ Minimal mode — just tinting, for modals and sidebars +

+
+
+ ), +}; + +export const NoLavis: Story = { + args: { showLavis: false, showAtmosphere: true, timeOfDay: 'morning' }, + render: (args) => ( + + + + ), +}; diff --git a/apps/web/src/components/sumi/SumiCanvas.tsx b/apps/web/src/components/sumi/SumiCanvas.tsx new file mode 100644 index 000000000..8c5f696b5 --- /dev/null +++ b/apps/web/src/components/sumi/SumiCanvas.tsx @@ -0,0 +1,202 @@ +/** + * SumiCanvas — The living background layer system + * + * Like a 2D game world, the interface exists on stacked visual planes: + * + * z-0 Void — Pure color, tinted by time-of-day and season + * z-1 Lavis — Scanned ink wash artwork (parallax on scroll) + * z-2 Atmosphere — Haze, grain, ambient light gradient + * z-3 Content — UI components float here (children) + * + * When lavis assets aren't available yet, z-1 uses generated ink wash + * gradients that still create depth and movement. + */ +import { useRef, useEffect, type CSSProperties } from 'react'; +import { cn } from '@/lib/utils'; +import { useTimeOfDay, getTimeOfDayPalette, type TimeOfDayPalette } from '@/hooks/useTimeOfDay'; +import { useSeason } from '@/hooks/useSeason'; +import type { TimeOfDayPeriod } from '@/hooks/useTimeOfDay'; +import type { Season } from '@/hooks/useSeason'; + +export interface SumiCanvasProps { + children: React.ReactNode; + /** Show the ink wash art layer */ + showLavis?: boolean; + /** Show the atmospheric haze layer */ + showAtmosphere?: boolean; + /** Enable parallax on scroll */ + parallax?: boolean; + /** Custom lavis image URL (overrides seasonal) */ + lavisUrl?: string; + /** Lavis image opacity override (0–1) */ + lavisOpacity?: number; + /** Override time of day (for Storybook) */ + timeOfDay?: TimeOfDayPeriod; + /** Override season (for Storybook) */ + season?: Season; + /** Additional className for the content layer */ + className?: string; + /** Minimal mode — just tinting, no layers (for modals, sidebars) */ + minimal?: boolean; +} + +export function SumiCanvas({ + children, + showLavis = true, + showAtmosphere = true, + parallax = true, + lavisUrl, + lavisOpacity, + timeOfDay: timeOverride, + season: seasonOverride, + className, + minimal = false, +}: SumiCanvasProps) { + const tod = useTimeOfDay(true); + const seasonCtx = useSeason(seasonOverride); + const containerRef = useRef(null); + const lavisRef = useRef(null); + + // Use override palette if specified + const palette: TimeOfDayPalette = timeOverride ? getTimeOfDayPalette(timeOverride) : tod; + + // Parallax effect on scroll + useEffect(() => { + if (!parallax || minimal || !lavisRef.current) return; + + const handleScroll = () => { + if (!lavisRef.current) return; + const scrollY = window.scrollY; + // Lavis layer moves at 30% of scroll speed = depth illusion + lavisRef.current.style.transform = `translateY(${scrollY * 0.3}px)`; + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, [parallax, minimal]); + + if (minimal) { + return ( +
+ {children} +
+ ); + } + + return ( +
+ {/* z-0: Void — base color with time-of-day and seasonal tinting */} +