From aa3605c6c74872fb675862ae65c9b0fd97d45420 Mon Sep 17 00:00:00 2001 From: VelvetAbyss Date: Tue, 27 Oct 2026 17:15:09 +0800 Subject: [PATCH] feat(dashboard): add adjustable cloud train and spring header scenes --- .../features/dashboard/DashboardHeader.tsx | 29 ++- .../dashboard/header/CloudTrain.test.tsx | 73 +++++++ .../features/dashboard/header/CloudTrain.tsx | 40 ++++ .../header/DashboardHeader.scene.test.tsx | 159 ++++++++++++++ .../dashboard/header/HeaderSceneControls.tsx | 115 ++++++++++ .../dashboard/header/SpringField.test.tsx | 73 +++++++ .../features/dashboard/header/SpringField.tsx | 44 ++++ .../dashboard/header/cloudtrain/renderer.ts | 156 +++++++++++++ .../dashboard/header/cloudtrain/shader.ts | 178 +++++++++++++++ .../dashboard/header/cloudtrain/steam.ts | 47 ++++ .../dashboard/header/cloudtrain/trainModel.ts | 138 ++++++++++++ .../dashboard/header/dashboard-header.css | 103 +++++++++ .../dashboard/header/sceneSettings.ts | 85 ++++++++ .../header/springfield/renderer.test.ts | 31 +++ .../dashboard/header/springfield/renderer.ts | 140 ++++++++++++ .../dashboard/header/springfield/terrain.ts | 205 ++++++++++++++++++ .../dashboard/header/useHeaderScene.ts | 47 ++++ apps/web/src/styles.css | 8 + 18 files changed, 1664 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/features/dashboard/header/CloudTrain.test.tsx create mode 100644 apps/web/src/features/dashboard/header/CloudTrain.tsx create mode 100644 apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx create mode 100644 apps/web/src/features/dashboard/header/HeaderSceneControls.tsx create mode 100644 apps/web/src/features/dashboard/header/SpringField.test.tsx create mode 100644 apps/web/src/features/dashboard/header/SpringField.tsx create mode 100644 apps/web/src/features/dashboard/header/cloudtrain/renderer.ts create mode 100644 apps/web/src/features/dashboard/header/cloudtrain/shader.ts create mode 100644 apps/web/src/features/dashboard/header/cloudtrain/steam.ts create mode 100644 apps/web/src/features/dashboard/header/cloudtrain/trainModel.ts create mode 100644 apps/web/src/features/dashboard/header/sceneSettings.ts create mode 100644 apps/web/src/features/dashboard/header/springfield/renderer.test.ts create mode 100644 apps/web/src/features/dashboard/header/springfield/renderer.ts create mode 100644 apps/web/src/features/dashboard/header/springfield/terrain.ts create mode 100644 apps/web/src/features/dashboard/header/useHeaderScene.ts diff --git a/apps/web/src/features/dashboard/DashboardHeader.tsx b/apps/web/src/features/dashboard/DashboardHeader.tsx index 5abbd43..550af52 100644 --- a/apps/web/src/features/dashboard/DashboardHeader.tsx +++ b/apps/web/src/features/dashboard/DashboardHeader.tsx @@ -16,6 +16,11 @@ import '../life/life.css' import './header/dashboard-header.css' import ActiveIndicator from '../../shared/motion/ActiveIndicator' import DeskProps from './desk/DeskProps' +import CloudTrain from './header/CloudTrain' +import SpringField from './header/SpringField' +import HeaderSceneControls from './header/HeaderSceneControls' +import { useHeaderScene, useSceneReducedMotion } from './header/useHeaderScene' +import { useThemeMode } from '../../shared/theme/useThemeMode' export type DashboardPage = 'main' | 'life' | 'news' | 'custom' @@ -81,6 +86,13 @@ const DashboardHeader = ({ onSetPage, }: DashboardHeaderProps) => { const { language, t } = useI18n() + const [scene, updateScene] = useHeaderScene() + const reducedMotion = useSceneReducedMotion() + const theme = useThemeMode() + const trainPalette = scene.palette === 'auto' ? (theme === 'dark' ? 'night' : 'sunset') : scene.palette + const springPalette = scene.spring.palette === 'auto' ? (theme === 'dark' ? 'night' : 'day') : scene.spring.palette + const scenery = scene.mode !== 'desk' + const activeSettings = scene.mode === 'spring' ? scene.spring : scene const headerRef = useRef(null) const today = useToday() const dayKey = dayKeyOf(today) @@ -142,8 +154,10 @@ const DashboardHeader = ({ // its epigraph), then a ruled toolbar to operate: the views as tabs on the // left, the layout actions on the right (DESIGN.md › Dashboard header). return ( -
-
+
+ {scene.mode === 'cloudtrain' && } + {scene.mode === 'spring' && } +
80 ? { minHeight: activeSettings.height } : undefined}>
) : } - {showLayoutActions ? ( -
+
+ + {showLayoutActions ? <> {layoutEdit ? ( -
- ) : null} + : null} +
- + {scene.mode === 'desk' && }
) } diff --git a/apps/web/src/features/dashboard/header/CloudTrain.test.tsx b/apps/web/src/features/dashboard/header/CloudTrain.test.tsx new file mode 100644 index 0000000..afaa782 --- /dev/null +++ b/apps/web/src/features/dashboard/header/CloudTrain.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { StrictMode } from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import CloudTrain from './CloudTrain' +import { TRAIN_DEFAULTS } from './sceneSettings' +import { useHeaderScene, useSceneReducedMotion, HEADER_SCENE_KEY } from './useHeaderScene' +import { createTrainRenderer } from './cloudtrain/renderer' + +// Auto-mocking also supports StrictMode's two concurrent dynamic imports; +// Vitest 3's manual factory recursion guard bypasses the mock for the second. +vi.mock('./cloudtrain/renderer') +const renderer = { create: vi.mocked(createTrainRenderer), update: vi.fn(), dispose: vi.fn() } +let reduced = false +const mediaListeners = new Set<() => void>() +let connection: EventTarget & { saveData: boolean } +beforeEach(() => { + localStorage.clear(); reduced = false; mediaListeners.clear() + delete document.documentElement.dataset.motion + connection = Object.assign(new EventTarget(), { saveData: false }) + Object.defineProperty(navigator, 'connection', { configurable: true, value: connection }) + vi.stubGlobal('matchMedia', () => ({ get matches() { return reduced }, addEventListener: (_: string, fn: () => void) => mediaListeners.add(fn), removeEventListener: (_: string, fn: () => void) => mediaListeners.delete(fn) })) + renderer.create.mockReset(); renderer.update.mockReset(); renderer.dispose.mockReset() + renderer.create.mockImplementation((_canvas, _options, onReady) => { onReady(true); return { update: renderer.update, dispose: renderer.dispose } }) +}) +afterEach(async () => { cleanup(); await vi.dynamicImportSettled(); vi.restoreAllMocks(); vi.unstubAllGlobals(); delete document.documentElement.dataset.motion }) + +function Harness() { + const [scene, update] = useHeaderScene() + const reducedMotion = useSceneReducedMotion() + return <> +} +describe('cloud train component lifecycle', () => { + it('does not create a renderer after an asynchronous mount was cancelled', async () => { + const { unmount } = render() + unmount() + await act(async () => { await vi.dynamicImportSettled() }) + expect(renderer.create).not.toHaveBeenCalled() + }) + it('creates only one live renderer in StrictMode and disposes it when leaving', async () => { + const { rerender, unmount } = render() + await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1)) + rerender() + expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, speed: 1.2, steam: 0, trainScale: 140, palette: 'night', paused: true }) + expect(renderer.create).toHaveBeenCalledTimes(1) + unmount(); expect(renderer.dispose).toHaveBeenCalledTimes(1) + }) + it('keeps the still illustration when WebGL cannot render', async () => { + renderer.create.mockImplementation((_canvas, _options, onReady) => { onReady(false); return { update: renderer.update, dispose: renderer.dispose } }) + const { container } = render() + await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1)) + expect(container.querySelector('.dash-cloudtrain')?.getAttribute('data-ready')).toBe('false') + expect(container.querySelector('svg')).not.toBeNull() + }) + it('respects OS, app, save-data and persisted manual pause independently', async () => { + const { unmount } = render() + await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1)) + act(() => { reduced = true; mediaListeners.forEach(fn => fn()) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: true }) + await act(async () => { document.documentElement.dataset.motion = 'reduce'; reduced = false; mediaListeners.forEach(fn => fn()) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: true }) + act(() => { connection.saveData = true; connection.dispatchEvent(new Event('change')) }) + await act(async () => { delete document.documentElement.dataset.motion }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: true }) + act(() => { connection.saveData = false; connection.dispatchEvent(new Event('change')) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'sunset', paused: false }) + act(() => { localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ paused: true, palette: 'night' })); window.dispatchEvent(new StorageEvent('storage', { key: HEADER_SCENE_KEY })) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'night', paused: true }) + act(() => { reduced = true; mediaListeners.forEach(fn => fn()); reduced = false; mediaListeners.forEach(fn => fn()) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...TRAIN_DEFAULTS, palette: 'night', paused: true }) + unmount(); expect(mediaListeners.size).toBe(0) + }) +}) diff --git a/apps/web/src/features/dashboard/header/CloudTrain.tsx b/apps/web/src/features/dashboard/header/CloudTrain.tsx new file mode 100644 index 0000000..2488323 --- /dev/null +++ b/apps/web/src/features/dashboard/header/CloudTrain.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef, useState, type CSSProperties } from 'react' +import type { TrainPalette, TrainRenderer } from './cloudtrain/renderer' +import { TRAIN_DEFAULTS, type TrainSettings } from './sceneSettings' + +export default function CloudTrain({ palette, paused, settings = TRAIN_DEFAULTS, anchor = 'bottom' }: { palette: TrainPalette; paused: boolean; settings?: TrainSettings; anchor?: 'top' | 'bottom' }) { + const canvas = useRef(null) + const renderer = useRef(null) + const options = useRef({ ...settings, palette, paused }) + const [ready, setReady] = useState(false) + useEffect(() => { + options.current = { ...settings, palette, paused } + renderer.current?.update(options.current) + }, [palette, paused, settings]) + useEffect(() => { + let cancelled = false + void import('./cloudtrain/renderer').then(({ createTrainRenderer }) => { + if (cancelled || !canvas.current) return + renderer.current = createTrainRenderer(canvas.current, options.current, setReady) + }).catch(() => { if (!cancelled) setReady(false) }) + return () => { cancelled = true; renderer.current?.dispose(); renderer.current = null } + }, []) + return ( + + ) +} diff --git a/apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx b/apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx new file mode 100644 index 0000000..b439407 --- /dev/null +++ b/apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import '@testing-library/jest-dom/vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import DashboardHeader from '../DashboardHeader' +import { ToastContext } from '../../../shared/ui/toast/toast' +import { HEADER_SCENE_KEY, readHeaderScene } from './useHeaderScene' +import { DEFAULT_HEADER_SCENE, TRAIN_DEFAULTS, SPRING_DEFAULTS } from './sceneSettings' + +vi.mock('../../../shared/i18n/useI18n', () => ({ useI18n: () => ({ language: 'zh', t: (key: string) => key }) })) +vi.mock('../../../shared/theme/useThemeMode', () => ({ useThemeMode: () => 'light' })) +vi.mock('../../../shared/ui/DiscoveryNewBadge', () => ({ DiscoveryNewBadge: () => null })) +vi.mock('../../../shared/motion/ActiveIndicator', () => ({ default: () => null })) +vi.mock('../LiveClock', () => ({ default: () => 10:24 })) +vi.mock('../quote/quoteService', async (importOriginal) => ({ ...(await importOriginal()), quoteForDay: (_day: Date, _language: string, skip: number) => ({ id: String(skip), content: skip ? '另一条真实引言。' : '保留每日引言。', author: '作者' }) })) +vi.mock('../../calendar/dayNotes/TodayNotesLine', () => ({ default: () => 今日便签 })) +vi.mock('../desk/DeskProps', () => ({ default: () =>
Desk
})) +vi.mock('./CloudTrain', () => ({ default: () =>
Train
})) +vi.mock('./SpringField', () => ({ default: () =>
Spring
})) +beforeEach(() => { localStorage.clear(); vi.stubGlobal('matchMedia', () => ({ matches: false, addEventListener() {}, removeEventListener() {} })) }) +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) +const onSetPage = vi.fn(), onToggleLayoutEdit = vi.fn() +const renderHeader = () => render() + +describe('dashboard header scene integration', () => { + it('keeps numeric dimensions consistent when repeated out-of-range entries normalize to the same value', () => { + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, mode: 'spring', spring: { ...SPRING_DEFAULTS, width: 150, sceneHeight: 32 } })) + renderHeader() + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + const width = screen.getByRole('spinbutton', { name: '输入动画宽度' }) + fireEvent.change(width, { target: { value: '999' } }); fireEvent.blur(width) + expect(width).toHaveValue(150) + const height = screen.getByRole('spinbutton', { name: '输入动画高度' }) + fireEvent.change(height, { target: { value: '-10' } }); fireEvent.blur(height) + expect(height).toHaveValue(32) + expect(readHeaderScene().spring).toMatchObject({ width: 150, sceneHeight: 32 }) + }) + it('preserves independent train and spring settings across switching, remounting and current-scene resets', () => { + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, width: 70, sceneHeight: 160, sceneX: -10, steam: 125, palette: 'night', paused: true })) + const first = renderHeader() + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + fireEvent.click(screen.getByRole('button', { name: '春日田野' })) + expect(screen.getByTestId('spring')).toBeInTheDocument() + expect(screen.queryByTestId('train')).toBeNull() + expect(screen.queryByTestId('desk')).toBeNull() + fireEvent.change(screen.getByRole('spinbutton', { name: '输入动画宽度' }), { target: { value: '65' } }) + fireEvent.blur(screen.getByRole('spinbutton', { name: '输入动画宽度' })) + fireEvent.change(screen.getByRole('slider', { name: '动画高度' }), { target: { value: '220' } }) + fireEvent.change(screen.getByRole('slider', { name: '动画左右位置' }), { target: { value: '12' } }) + fireEvent.change(screen.getByRole('slider', { name: '动画上下位置' }), { target: { value: '-5' } }) + fireEvent.change(screen.getByRole('slider', { name: '云影浓度', hidden: true }), { target: { value: '25' } }) + fireEvent.click(screen.getByRole('button', { name: '晚霞' })) + expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '220px' }) + expect(readHeaderScene()).toMatchObject({ width: 70, sceneHeight: 160, sceneX: -10, steam: 125, palette: 'night', paused: true, + spring: { width: 65, sceneHeight: 220, sceneX: 12, sceneY: -5, clouds: 25, palette: 'dusk' } }) + fireEvent.click(screen.getByRole('button', { name: '云间列车' })) + expect(screen.getByRole('slider', { name: '动画宽度' })).toHaveValue('70') + expect(screen.queryByTestId('spring')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: '恢复默认' })) + expect(readHeaderScene().spring).toMatchObject({ width: 65, sceneHeight: 220, sceneX: 12, clouds: 25 }) + fireEvent.click(screen.getByRole('button', { name: '春日田野' })) + first.unmount() + const second = renderHeader() + expect(screen.getByTestId('spring')).toBeInTheDocument() + expect(second.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '220px' }) + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + fireEvent.click(screen.getByRole('button', { name: '动画位置归位' })) + expect(readHeaderScene().spring).toMatchObject({ width: 65, sceneHeight: 220, sceneX: 0, sceneY: 0, sceneAnchor: 'bottom' }) + fireEvent.click(screen.getByRole('button', { name: '恢复默认' })) + expect(readHeaderScene()).toEqual({ ...DEFAULT_HEADER_SCENE, mode: 'spring' }) + fireEvent.click(screen.getByRole('button', { name: '桌面摆件' })) + expect(second.container.querySelector('.dash-header')?.getAttribute('style')).not.toContain('min-height') + }) + it('adds separate spring defaults to legacy train preferences and sanitizes damaged spring settings', () => { + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ width: 77, sceneX: -1, trainScale: 65, sceneAnchor: 'bottom' })) + expect(readHeaderScene()).toMatchObject({ width: 77, sceneX: -1, trainScale: 65, spring: SPRING_DEFAULTS }) + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ mode: 'spring', spring: { width: 999, sceneHeight: -9, sceneY: -999, zoom: 'big', clouds: 140, plane: false, palette: 'sunset' } })) + expect(readHeaderScene().spring).toEqual({ ...SPRING_DEFAULTS, width: 150, sceneHeight: 32, sceneY: -50, clouds: 100, plane: false }) + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ mode: 'spring', spring: null })) + expect(readHeaderScene().spring).toEqual(SPRING_DEFAULTS) + }) + it('preserves the real date, clock, quote and dashboard actions around the train', () => { + const { container } = renderHeader() + expect(screen.getByTestId('train')).toBeInTheDocument() + expect(screen.queryByTestId('desk')).toBeNull() + expect(container.querySelector('time')?.getAttribute('dateTime')).toMatch(/^\d{4}-\d{2}-\d{2}$/) + expect(screen.getByText('10:24')).toBeInTheDocument() + expect(screen.getByTestId('today-notes')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dashboard.quote.keep' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'dashboard.quote.open' })).toBeInTheDocument() + expect(screen.getByText('“保留每日引言。”')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'dashboard.quote.next' })) + expect(screen.getByText('“另一条真实引言。”')).toBeInTheDocument() + fireEvent.click(screen.getByRole('tab', { name: 'dashboard.page.life' })) + expect(onSetPage).toHaveBeenLastCalledWith('life') + fireEvent.click(screen.getByRole('button', { name: 'dashboard.editLayout' })) + expect(onToggleLayoutEdit).toHaveBeenCalled() + }) + it('switches to existing desk objects and persists that choice across remounts', () => { + const first = renderHeader() + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + fireEvent.click(screen.getByRole('button', { name: '桌面摆件' })) + expect(screen.getByTestId('desk')).toBeInTheDocument() + expect(screen.queryByTestId('train')).toBeNull() + first.unmount(); renderHeader() + expect(screen.getByTestId('desk')).toBeInTheDocument() + expect(readHeaderScene().mode).toBe('desk') + }) + it('keeps palette and pause choices locally without changing workspace data', () => { + renderHeader() + fireEvent.click(screen.getByRole('button', { name: '暂停列车动画' })) + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + fireEvent.click(screen.getByRole('button', { name: '夜航' })) + expect(JSON.parse(localStorage.getItem(HEADER_SCENE_KEY)!)).toEqual({ ...DEFAULT_HEADER_SCENE, palette: 'night', paused: true }) + }) + it('recovers from invalid or unavailable preference storage', () => { + localStorage.setItem(HEADER_SCENE_KEY, '{broken') + expect(readHeaderScene()).toEqual(DEFAULT_HEADER_SCENE) + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('blocked') }) + renderHeader() + fireEvent.click(screen.getByRole('button', { name: '暂停列车动画' })) + expect(screen.getByRole('button', { name: '播放列车动画' })).toBeInTheDocument() + }) + it('previews custom dimensions, preserves them on reload and restores the original layout', () => { + const first = renderHeader() + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + fireEvent.change(screen.getByLabelText('顶栏高度', { selector: 'input' }), { target: { value: '160' } }) + fireEvent.change(screen.getByLabelText('动画宽度', { selector: 'input[type="range"]' }), { target: { value: '70' } }) + fireEvent.change(screen.getByLabelText('动画高度', { selector: 'input[type="range"]' }), { target: { value: '200' } }) + fireEvent.change(screen.getByLabelText('动画左右位置', { selector: 'input' }), { target: { value: '-20' } }) + fireEvent.change(screen.getByLabelText('动画上下位置', { selector: 'input' }), { target: { value: '25' } }) + fireEvent.change(screen.getByLabelText('列车大小', { selector: 'input' }), { target: { value: '140' } }) + fireEvent.change(screen.getByLabelText('行进速度', { selector: 'input' }), { target: { value: '1.2' } }) + expect(first.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '160px' }) + expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '200px' }) + expect(readHeaderScene()).toMatchObject({ height: 160, sceneHeight: 200, width: 70, trainScale: 140, speed: 1.2, sceneX: -20, sceneY: 25 }) + first.unmount() + const second = renderHeader() + expect(second.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '160px' }) + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + expect(screen.getByLabelText('动画左右位置', { selector: 'input' })).toHaveValue('-20') + expect(screen.getByLabelText('动画上下位置', { selector: 'input' })).toHaveValue('25') + fireEvent.click(screen.getByRole('button', { name: '贴紧顶栏底部' })) + expect(readHeaderScene()).toMatchObject({ sceneAnchor: 'bottom', sceneY: 0, sceneHeight: 200, width: 70, sceneX: -20 }) + fireEvent.click(screen.getByRole('button', { name: '动画位置归位' })) + expect(readHeaderScene()).toMatchObject({ sceneX: 0, sceneY: 0, height: 160, width: 70, trainScale: 140, speed: 1.2 }) + fireEvent.click(screen.getByRole('button', { name: '恢复默认' })) + expect(readHeaderScene()).toEqual(DEFAULT_HEADER_SCENE) + expect(second.container.querySelector('.dash-hero')?.getAttribute('style')).not.toContain('min-height') + }) + it('upgrades old preferences and bounds invalid sizes without discarding valid choices', () => { + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ palette: 'night', paused: true })) + expect(readHeaderScene()).toEqual({ ...DEFAULT_HEADER_SCENE, palette: 'night', paused: true }) + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ height: 9000, sceneHeight: 9999, width: -20, speed: 'fast', steam: null, trainScale: 1000, sceneX: -900, sceneY: 900, sceneAnchor: 'bottom' })) + expect(readHeaderScene()).toMatchObject({ height: 240, sceneHeight: 320, width: 10, speed: TRAIN_DEFAULTS.speed, steam: TRAIN_DEFAULTS.steam, trainScale: 170, sceneX: -40, sceneY: 50 }) + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ sceneX: -20, sceneY: 50, width: 70, steam: 125 })) + expect(readHeaderScene()).toMatchObject({ sceneAnchor: 'bottom', sceneHeight: 80, sceneX: -20, sceneY: 0, width: 70, steam: 125 }) + }) +}) diff --git a/apps/web/src/features/dashboard/header/HeaderSceneControls.tsx b/apps/web/src/features/dashboard/header/HeaderSceneControls.tsx new file mode 100644 index 0000000..519ba88 --- /dev/null +++ b/apps/web/src/features/dashboard/header/HeaderSceneControls.tsx @@ -0,0 +1,115 @@ +import { useId } from 'react' +import { Image, LampDesk, Pause, Play, RotateCcw, TrainFront, Flower2 } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '../../../shared/ui/popover' +import { useI18n } from '../../../shared/i18n/useI18n' +import type { HeaderScene } from './useHeaderScene' +import { TRAIN_DEFAULTS, TRAIN_RANGES, SPRING_DEFAULTS, SPRING_RANGES, type TrainSetting, type SpringNumericSetting, type HeaderScenePatch } from './sceneSettings' + +export default function HeaderSceneControls({ scene, update, reduced }: { + scene: HeaderScene; update: (patch: HeaderScenePatch) => void; reduced: boolean +}) { + const { language } = useI18n() + const zh = language === 'zh' + const id = useId() + const spring = scene.mode === 'spring' + const active = spring ? scene.spring : scene + // All numeric fields have explicit ranges; only the active scene's controls render. + const values = spring ? { ...scene, ...scene.spring } : { ...scene.spring, ...scene } + const ranges = spring ? { ...TRAIN_RANGES, ...SPRING_RANGES } : { ...SPRING_RANGES, ...TRAIN_RANGES } + const updateActive = (patch: Partial>) => update(spring ? { spring: patch } : patch) + const slider = (key: TrainSetting | SpringNumericSetting, label: string) => { + const value = values[key] + const direction = key === 'sceneX' + ? (value < 0 ? (zh ? '左移' : 'Left') : (zh ? '右移' : 'Right')) + : (value < 0 ? (zh ? '上移' : 'Up') : (zh ? '下移' : 'Down')) + const formatted = key === 'speed' ? `${value.toFixed(2)}×` + : key === 'sceneHeight' ? `${value}px` + : key === 'height' ? (value === 80 ? (zh ? '原尺寸' : 'Original') : `${value}px`) + : key === 'sceneX' || key === 'sceneY' ? (value === 0 ? (zh ? '原位' : 'Original') : `${direction} ${Math.abs(value)}%`) + : key === 'railOffset' ? (value === 0 ? (zh ? '居中' : 'Centered') : `${value > 0 ? '+' : ''}${value}%`) + : `${value}%` + return
+ {key === 'width' || key === 'sceneHeight' ? + { + const next = event.currentTarget.valueAsNumber + const bounded = Number.isFinite(next) ? Math.min(ranges[key].max, Math.max(ranges[key].min, next)) : value + event.currentTarget.value = String(bounded) + if (Number.isFinite(next)) updateActive({ [key]: bounded }) + }} onKeyDown={event => { if (event.key === 'Enter') event.currentTarget.blur(); if (event.key === 'Escape') { event.currentTarget.value = String(value); event.currentTarget.blur() } }} /> + + : {formatted}} + updateActive({ [key]: Number(event.target.value) })} /> +
+ } + const pauseLabel = reduced ? (zh ? '已减少动态效果' : 'Reduced motion is on') + : active.paused ? (zh ? (spring ? '播放田野动画' : '播放列车动画') : (spring ? 'Play field animation' : 'Play train animation')) : (zh ? (spring ? '暂停田野动画' : '暂停列车动画') : (spring ? 'Pause field animation' : 'Pause train animation')) + const palettes = [ + ['auto', zh ? '跟随主题' : 'Match theme'], ['sunset', zh ? '落日' : 'Sunset'], + ['morning', zh ? '晨光' : 'Morning'], ['night', zh ? '夜航' : 'Night'], + ] as const + return ( + <> + {scene.mode !== 'desk' && } + + + + + +
{zh ? '顶栏风景' : 'Header scenery'}
+
+ + + +
+ {scene.mode !== 'desk' && <> + {!spring && <>
{zh ? '沿途光景' : 'Along the way'}
+
+ {palettes.map(([value, label]) => )} +
+ } + {spring && <>
{zh ? '田野光景' : 'Field light'}
+
+ {([['auto', zh ? '跟随主题' : 'Match theme'], ['day', zh ? '春日' : 'Daylight'], ['dusk', zh ? '晚霞' : 'Dusk'], ['night', zh ? '月色' : 'Moonlight']] as const).map(([value, label]) => )} +
+ } +
+ {zh ? '尺寸与位置' : 'Size & placement'} +
+ + +
+ {slider('width', zh ? '动画宽度' : 'Scenery width')} + {slider('sceneHeight', zh ? '动画高度' : 'Scenery height')} + {slider('sceneX', zh ? '动画左右位置' : 'Scenery horizontal position')} + {slider('sceneY', zh ? '动画上下位置' : 'Scenery vertical position')} +
+

{zh ? '贴底时调整高度会向上展开。宽高可直接输入,放大时顶栏自动留出空间。' : 'Pinned scenery grows upward. Enter a width or height; the header makes room as needed.'}

+ {spring ? slider('zoom', zh ? '景物大小' : 'Landscape scale') : <>{slider('trainScale', zh ? '列车大小' : 'Train size')}{slider('railOffset', zh ? '列车上下位置' : 'Train vertical position')}} + {slider('opacity', zh ? '风景浓度' : 'Scenery opacity')} + {slider('height', zh ? '顶栏高度' : 'Header height')} +

{zh ? '默认沿用原顶栏尺寸,窄屏会自动留出文字空间。' : 'Original header size by default. Small screens keep room for text.'}

+
+
+ {zh ? (spring ? '田野与动画' : '云海与动画') : (spring ? 'Fields & motion' : 'Clouds & motion')} + {slider('speed', zh ? '行进速度' : 'Travel speed')} + {spring ? <>{slider('clouds', zh ? '云影浓度' : 'Cloud density')}{slider('texture', zh ? '纸面颗粒' : 'Paper grain')} : <> + {slider('billow', zh ? '云海起伏' : 'Cloud billow')} + {slider('detail', zh ? '云层细节' : 'Cloud detail')} + {slider('cloudLevel', zh ? '下方云海高度' : 'Cloud sea level')} + {slider('steam', zh ? '蒸汽浓度' : 'Steam density')}} + {reduced &&

{zh ? '已跟随设备设置暂停动态效果,外观仍可调整。' : 'Device motion settings pause animation. Appearance can still be adjusted.'}

} +
+
+ {zh ? '各场景分别保存在此设备' : 'Saved separately for each scene'} + +
+ } +
+
+ + ) +} diff --git a/apps/web/src/features/dashboard/header/SpringField.test.tsx b/apps/web/src/features/dashboard/header/SpringField.test.tsx new file mode 100644 index 0000000..885fbb6 --- /dev/null +++ b/apps/web/src/features/dashboard/header/SpringField.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom +import { StrictMode } from 'react' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import SpringField from './SpringField' +import { SPRING_DEFAULTS } from './sceneSettings' +import { useHeaderScene, useSceneReducedMotion, HEADER_SCENE_KEY } from './useHeaderScene' +import { createSpringRenderer } from './springfield/renderer' + +// Auto-mocking also supports StrictMode's two concurrent dynamic imports; +// Vitest 3's manual factory recursion guard bypasses the mock for the second. +vi.mock('./springfield/renderer') +const renderer = { create: vi.mocked(createSpringRenderer), update: vi.fn(), dispose: vi.fn() } +let reduced = false +const mediaListeners = new Set<() => void>() +let connection: EventTarget & { saveData: boolean } +beforeEach(() => { + localStorage.clear(); reduced = false; mediaListeners.clear() + delete document.documentElement.dataset.motion + connection = Object.assign(new EventTarget(), { saveData: false }) + Object.defineProperty(navigator, 'connection', { configurable: true, value: connection }) + vi.stubGlobal('matchMedia', () => ({ get matches() { return reduced }, addEventListener: (_: string, fn: () => void) => mediaListeners.add(fn), removeEventListener: (_: string, fn: () => void) => mediaListeners.delete(fn) })) + renderer.create.mockReset(); renderer.update.mockReset(); renderer.dispose.mockReset() + renderer.create.mockImplementation((_canvas, _options, onReady) => { onReady(true); return { update: renderer.update, dispose: renderer.dispose } }) +}) +afterEach(async () => { cleanup(); await vi.dynamicImportSettled(); vi.restoreAllMocks(); vi.unstubAllGlobals(); delete document.documentElement.dataset.motion }) + +function Harness() { + const [scene, update] = useHeaderScene() + const reducedMotion = useSceneReducedMotion() + return <> +} +describe('spring field component lifecycle', () => { + it('does not create a renderer after an asynchronous mount was cancelled', async () => { + const { unmount } = render() + unmount() + await act(async () => { await vi.dynamicImportSettled() }) + expect(renderer.create).not.toHaveBeenCalled() + }) + it('creates only one live renderer in StrictMode and disposes it when leaving', async () => { + const { rerender, unmount } = render() + await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1)) + rerender() + expect(renderer.update).toHaveBeenLastCalledWith({ ...SPRING_DEFAULTS, speed: 1.2, clouds: 0, zoom: 140, palette: 'night', paused: true }) + expect(renderer.create).toHaveBeenCalledTimes(1) + unmount(); expect(renderer.dispose).toHaveBeenCalledTimes(1) + }) + it('keeps the still illustration when Canvas cannot render', async () => { + renderer.create.mockImplementation((_canvas, _options, onReady) => { onReady(false); return { update: renderer.update, dispose: renderer.dispose } }) + const { container } = render() + await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1)) + expect(container.querySelector('.dash-springfield')?.getAttribute('data-ready')).toBe('false') + expect(container.querySelector('svg')).not.toBeNull() + }) + it('respects OS, app, save-data and persisted manual pause independently', async () => { + const { unmount } = render() + await waitFor(() => expect(renderer.create).toHaveBeenCalledTimes(1)) + act(() => { reduced = true; mediaListeners.forEach(fn => fn()) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...SPRING_DEFAULTS, palette: 'day', paused: true }) + await act(async () => { document.documentElement.dataset.motion = 'reduce'; reduced = false; mediaListeners.forEach(fn => fn()) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...SPRING_DEFAULTS, palette: 'day', paused: true }) + act(() => { connection.saveData = true; connection.dispatchEvent(new Event('change')) }) + await act(async () => { delete document.documentElement.dataset.motion }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...SPRING_DEFAULTS, palette: 'day', paused: true }) + act(() => { connection.saveData = false; connection.dispatchEvent(new Event('change')) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...SPRING_DEFAULTS, palette: 'day', paused: false }) + act(() => { localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ spring: { paused: true, palette: 'night' } })); window.dispatchEvent(new StorageEvent('storage', { key: HEADER_SCENE_KEY })) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...SPRING_DEFAULTS, palette: 'night', paused: true }) + act(() => { reduced = true; mediaListeners.forEach(fn => fn()); reduced = false; mediaListeners.forEach(fn => fn()) }) + expect(renderer.update).toHaveBeenLastCalledWith({ ...SPRING_DEFAULTS, palette: 'night', paused: true }) + unmount(); expect(mediaListeners.size).toBe(0) + }) +}) diff --git a/apps/web/src/features/dashboard/header/SpringField.tsx b/apps/web/src/features/dashboard/header/SpringField.tsx new file mode 100644 index 0000000..ce46d43 --- /dev/null +++ b/apps/web/src/features/dashboard/header/SpringField.tsx @@ -0,0 +1,44 @@ +import { useEffect, useId, useRef, useState, type CSSProperties } from 'react' +import type { SpringPalette, SpringSettings } from './sceneSettings' +import type { SpringRenderer } from './springfield/renderer' + +export default function SpringField({ settings, palette, paused }: { + settings: SpringSettings; palette: SpringPalette; paused: boolean +}) { + const canvas = useRef(null) + const renderer = useRef(null) + const options = useRef({ ...settings, palette, paused }) + const [ready, setReady] = useState(false) + const fieldPattern = useId() + useEffect(() => { + options.current = { ...settings, palette, paused } + renderer.current?.update(options.current) + }, [settings, palette, paused]) + useEffect(() => { + let cancelled = false + void import('./springfield/renderer').then(({ createSpringRenderer }) => { + if (cancelled || !canvas.current) return + renderer.current = createSpringRenderer(canvas.current, options.current, setReady) + }).catch(() => { if (!cancelled) setReady(false) }) + return () => { cancelled = true; renderer.current?.dispose(); renderer.current = null } + }, []) + return +} diff --git a/apps/web/src/features/dashboard/header/cloudtrain/renderer.ts b/apps/web/src/features/dashboard/header/cloudtrain/renderer.ts new file mode 100644 index 0000000..dbd8fb4 --- /dev/null +++ b/apps/web/src/features/dashboard/header/cloudtrain/renderer.ts @@ -0,0 +1,156 @@ +import { vertex, fragment } from './shader' +import { BRIDGE, makeBridge, makeTrain } from './trainModel' +import { createSteam } from './steam' +import type { TrainPalette, TrainSettings } from '../sceneSettings' + +export type { TrainPalette } from '../sceneSettings' +export type TrainRenderOptions = TrainSettings & { palette: TrainPalette; paused: boolean } +export type TrainRenderer = { update: (options: TrainRenderOptions) => void; dispose: () => void } + +/** One canvas owns its GPU resources and loop. No document-wide DOM queries. */ +export function createTrainRenderer( + canvas: HTMLCanvasElement, + initial: TrainRenderOptions, + onReady: (ready: boolean) => void, +): TrainRenderer { + const gl = canvas.getContext('webgl', { + alpha: false, antialias: false, depth: false, stencil: false, + preserveDrawingBuffer: false, powerPreference: 'low-power', + }) + if (!gl) { onReady(false); return { update() {}, dispose() {} } } + let options = initial + let program: WebGLProgram | null = null, buffer: WebGLBuffer | null = null + let textures: WebGLTexture[] = [] + let uniforms: Record = {} + let disposed = false, lost = false, ready = false, visible = true, raf = 0, last = 0 + let time = 0, travel = 0, viewportHeight = 1 + const steam = createSteam() + steam.reset(options.speed * BRIDGE.worldWidth * BRIDGE.drift) + + function releaseResources() { + gl!.useProgram(null) + textures.forEach(texture => gl!.deleteTexture(texture)); textures = [] + if (buffer) gl!.deleteBuffer(buffer) + if (program) gl!.deleteProgram(program) + buffer = null; program = null + } + function compile(type: number, source: string) { + const shader = gl!.createShader(type) + if (!shader) throw new Error('Shader allocation failed') + gl!.shaderSource(shader, source); gl!.compileShader(shader) + if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) { + gl!.deleteShader(shader); throw new Error('Shader compilation failed') + } + return shader + } + function texture(source: HTMLCanvasElement | Uint8Array, unit: number, repeat = false) { + const tex = gl!.createTexture() + if (!tex) throw new Error('Texture allocation failed') + textures.push(tex); gl!.activeTexture(gl!.TEXTURE0 + unit); gl!.bindTexture(gl!.TEXTURE_2D, tex) + gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_MIN_FILTER, gl!.LINEAR) + gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_MAG_FILTER, gl!.LINEAR) + gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_WRAP_S, repeat ? gl!.REPEAT : gl!.CLAMP_TO_EDGE) + gl!.texParameteri(gl!.TEXTURE_2D, gl!.TEXTURE_WRAP_T, repeat ? gl!.REPEAT : gl!.CLAMP_TO_EDGE) + if (source instanceof Uint8Array) gl!.texImage2D(gl!.TEXTURE_2D, 0, gl!.RGBA, 256, 256, 0, gl!.RGBA, gl!.UNSIGNED_BYTE, source) + else gl!.texImage2D(gl!.TEXTURE_2D, 0, gl!.RGBA, gl!.RGBA, gl!.UNSIGNED_BYTE, source) + } + function draw() { + if (!ready || lost || disposed) return + gl!.uniform2f(uniforms.resolution, canvas.width, canvas.height) + gl!.uniform1f(uniforms.time, time); gl!.uniform1f(uniforms.travel, travel) + gl!.uniform1f(uniforms.billow, options.billow / 100); gl!.uniform1f(uniforms.detail, options.detail / 100) + gl!.uniform1f(uniforms.steamAmount, options.steam / 100) + gl!.uniform1f(uniforms.trainScale, options.trainScale / 100) + gl!.uniform1f(uniforms.railOffset, options.railOffset / 100) + gl!.uniform1f(uniforms.cloudLevel, options.cloudLevel / 100) + gl!.uniform1f(uniforms.palette, { sunset: 0, morning: 1, night: 2 }[options.palette]) + gl!.uniform1f(uniforms.viewportHeight, viewportHeight) + gl!.uniform1f(uniforms.compact, window.matchMedia('(max-width: 960px)').matches ? 1 : 0) + gl!.uniform4fv(uniforms['steamParticles[0]'], steam.data) + gl!.drawArrays(gl!.TRIANGLES, 0, 6) + } + function resize() { + if (!ready || lost || disposed) return + const bounds = canvas.getBoundingClientRect() + viewportHeight = bounds.height + const ratio = Math.min(window.devicePixelRatio || 1, 1.25, Math.sqrt(600000 / Math.max(1, bounds.width * bounds.height))) + const width = Math.max(1, Math.round(bounds.width * ratio)), height = Math.max(1, Math.round(bounds.height * ratio)) + if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height } + gl!.viewport(0, 0, width, height); draw() + } + const shouldRun = () => ready && !disposed && !lost && !options.paused && options.speed > 0 && visible && !document.hidden + function tick(now: number) { + raf = 0 + if (!shouldRun()) { last = 0; return } + if (last && now - last < 1000 / 30 - 1) { raf = requestAnimationFrame(tick); return } + const dt = last ? Math.min((now - last) / 1000, .08) : 0 + time += dt; travel += dt * options.speed + steam.step(dt, options.speed * BRIDGE.worldWidth * BRIDGE.drift); last = now + draw(); raf = requestAnimationFrame(tick) + } + function schedule() { + if (shouldRun() && !raf) { last = 0; raf = requestAnimationFrame(tick) } + else if (!shouldRun()) { cancelAnimationFrame(raf); raf = 0; last = 0 } + } + function initialize() { + ready = false + let vs: WebGLShader | null = null, fs: WebGLShader | null = null + try { + vs = compile(gl!.VERTEX_SHADER, vertex); fs = compile(gl!.FRAGMENT_SHADER, fragment) + program = gl!.createProgram() + if (!program) throw new Error('Program allocation failed') + gl!.attachShader(program, vs); gl!.attachShader(program, fs); gl!.linkProgram(program) + if (!gl!.getProgramParameter(program, gl!.LINK_STATUS)) throw new Error('Shader link failed') + gl!.useProgram(program) + buffer = gl!.createBuffer() + if (!buffer) throw new Error('Buffer allocation failed') + gl!.bindBuffer(gl!.ARRAY_BUFFER, buffer) + gl!.bufferData(gl!.ARRAY_BUFFER, new Float32Array([-1,-1,1,-1,-1,1,-1,1,1,-1,1,1]), gl!.STATIC_DRAW) + const position = gl!.getAttribLocation(program, 'position') + gl!.enableVertexAttribArray(position); gl!.vertexAttribPointer(position, 2, gl!.FLOAT, false, 0, 0) + uniforms = Object.fromEntries(['resolution','time','travel','billow','detail','palette','viewportHeight','compact','steamAmount','trainScale','railOffset','cloudLevel','noiseMap','bridgeMap','trainMap','steamParticles[0]'].map(name => [name, gl!.getUniformLocation(program!, name)])) + const noise = new Uint8Array(256 * 256 * 4) + let seed = 89357 + for (let i = 0; i < noise.length; i += 4) { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0 + noise[i] = noise[i + 1] = noise[i + 2] = seed >>> 24; noise[i + 3] = 255 + } + texture(noise, 0, true); texture(makeBridge(), 1); texture(makeTrain(), 2) + gl!.uniform1i(uniforms.noiseMap, 0); gl!.uniform1i(uniforms.bridgeMap, 1); gl!.uniform1i(uniforms.trainMap, 2) + ready = true; resize(); onReady(true) + } catch { + releaseResources(); onReady(false) + } finally { + if (vs) gl!.deleteShader(vs) + if (fs) gl!.deleteShader(fs) + schedule() + } + } + const onLost = (event: Event) => { + event.preventDefault(); lost = true; ready = false; schedule(); onReady(false) + textures = []; program = null; buffer = null + } + const onRestored = () => { if (!disposed) { lost = false; initialize() } } + const intersection = typeof IntersectionObserver === 'undefined' ? null : new IntersectionObserver(entries => { + visible = entries[0]?.isIntersecting ?? true; schedule() + }, { threshold: .01 }) + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize) + intersection?.observe(canvas); observer?.observe(canvas) + window.addEventListener('resize', resize) + document.addEventListener('visibilitychange', schedule) + canvas.addEventListener('webglcontextlost', onLost) + canvas.addEventListener('webglcontextrestored', onRestored) + initialize() + return { + update(next) { if (disposed) return; options = next; draw(); schedule() }, + dispose() { + disposed = true; ready = false; cancelAnimationFrame(raf) + intersection?.disconnect(); observer?.disconnect() + window.removeEventListener('resize', resize) + document.removeEventListener('visibilitychange', schedule) + canvas.removeEventListener('webglcontextlost', onLost) + canvas.removeEventListener('webglcontextrestored', onRestored) + if (!lost) releaseResources() + }, + } +} diff --git a/apps/web/src/features/dashboard/header/cloudtrain/shader.ts b/apps/web/src/features/dashboard/header/cloudtrain/shader.ts new file mode 100644 index 0000000..70185e7 --- /dev/null +++ b/apps/web/src/features/dashboard/header/cloudtrain/shader.ts @@ -0,0 +1,178 @@ +// Original Focus&go Cloudbound shader, from the approved standalone V2. +import { TRAIN, BRIDGE } from './trainModel' +import { STEAM_COUNT } from './steam' +export const vertex = `attribute vec2 position; varying vec2 uv; +void main(){ uv = position * .5 + .5; gl_Position=vec4(position,0.,1.); }`; + +export const fragment = `precision highp float; +varying vec2 uv; +uniform vec2 resolution; +uniform float time, travel, billow, detail, palette, viewportHeight; +uniform float steamAmount, trainScale, railOffset, cloudLevel, compact; +uniform sampler2D noiseMap, bridgeMap, trainMap; +uniform vec4 steamParticles[${STEAM_COUNT}]; +float noise(vec2 p){ vec2 i=floor(p), f=fract(p); f=f*f*(3.-2.*f); + return texture2D(noiseMap,(i+f+.5)/256.).r; } +float fbm(vec2 p){ + float f=noise(p)*.55; p=p*2.03+vec2(11.3,7.2); + f+=noise(p)*.26; p=p*2.01+vec2(21.7,9.3); + f+=noise(p)*.13; p=p*2.06+vec2(5.3,19.1); + f+=noise(p)*.06*detail; p=p*2.01+vec2(17.9,2.3); + f+=noise(p)*.035*detail; p=p*2.03+vec2(8.1,13.7); + f+=noise(p)*.018*detail; + return f/(.94+.113*detail); +} +vec3 color(vec3 sunset,vec3 morning,vec3 night){return palette<.5?sunset:(palette<1.5?morning:night);} +// Each bank is a continuously evolving contour field, with painted shadow bands. +float cloud(vec2 p,float altitude,float scale,float drift,float seed){ + float x=p.x+travel*drift; + float spacing=.66/scale; + float cell=floor(x/spacing); + float top=altitude+.4; + // Overlapping circular updrafts provide rounded cumulus silhouettes. + // Small fractal perturbations roughen their edges without creating spires. + for(int i=-2;i<=2;i++){ + float id=cell+float(i); + float rnd=noise(vec2(id*3.7+seed,seed+11.)); + float cx=(id+.35+noise(vec2(id+seed,13.))*.3)*spacing; + float r=spacing*(.62+rnd*.43); + float dx=abs(x-cx); + float rise=sqrt(max(0.,r*r-dx*dx)); + float height=altitude+(rnd-.5)*(.045+billow*.25)-rise*(.22+billow*.85); + height+=sin(id*1.91+time*.045)*.015*billow; + if(dxradius+aa)return c; + float edge=1.-smoothstep(radius-aa*.5,radius+aa*.5,r); + float rim=1.-smoothstep(.7,1.5+aa*.4,abs(r-(radius-2.))); + float theta=atan(q.y,q.x)-distance/radius; + float spoke=(1.-smoothstep(.10,.22+aa*.01,abs(sin(theta*5.))))*step(4.,r)*step(r,radius-4.); + float hub=1.-smoothstep(2.,4.+aa*.3,r); + vec3 w=mix(iron*.70,metal*.62,rim*.75); + w=mix(w,metal*.82,max(spoke*.65,hub)); + return mix(c,w,edge); +} +void main(){ + vec2 st=vec2(uv.x,1.-uv.y); + float aspect=resolution.x/resolution.y; + // Crop the landscape to the existing masthead. Model scale stays about + // 140 CSS pixels wide without expanding the original layout. + float framing=max(.30,viewportHeight/220.); + float railScreen=mix(.55,.40,compact)-railOffset; + vec2 p=vec2((st.x-.5)*aspect*framing,(st.y-railScreen)*framing+.725); + float skyMix=smoothstep(.0,.86,p.y); + vec3 skyTop=color(vec3(.48,.60,.82),vec3(.60,.74,.78),vec3(.12,.18,.29)); + vec3 skyBottom=color(vec3(.96,.68,.45),vec3(.95,.87,.70),vec3(.41,.36,.49)); + vec3 c=mix(skyTop,skyBottom,skyMix); + // Subtle evening disk; mostly hidden behind the cloud banks. + float sun=1.-smoothstep(.065,.068,length(p-vec2(aspect*.25,.29))); + c=mix(c,color(vec3(1.,.87,.62),vec3(1.,.95,.80),vec3(.88,.88,.75)),sun*.75); + float d=cloud(p,.38,1.9,.047,12.7); + c=paint(c,d,color(vec3(.99,.91,.79),vec3(.98,.96,.86),vec3(.53,.57,.64)),color(vec3(.91,.80,.72),vec3(.81,.86,.79),vec3(.39,.43,.55)),color(vec3(.83,.68,.65),vec3(.64,.75,.72),vec3(.28,.33,.45)),.0015); + d=cloud(p,.49,2.4,.085,37.3); + c=paint(c,d,color(vec3(.85,.47,.34),vec3(.77,.79,.63),vec3(.48,.39,.53)),color(vec3(.74,.37,.31),vec3(.63,.69,.60),vec3(.35,.28,.43)),color(vec3(.62,.31,.31),vec3(.49,.60,.57),vec3(.24,.24,.37)),.0014); + d=cloud(p,.59,2.1,.125,7.4); + c=paint(c,d,color(vec3(1.,.73,.48),vec3(.98,.88,.68),vec3(.66,.53,.58)),color(vec3(1.,.60,.33),vec3(.90,.77,.57),vec3(.53,.40,.50)),color(vec3(.97,.50,.28),vec3(.76,.65,.51),vec3(.40,.32,.44)),.0013); + d=cloud(p,.72,3.5,.19,52.8); + c=paint(c,d,color(vec3(1.,.85,.63),vec3(.99,.92,.76),vec3(.66,.62,.68)),color(vec3(.93,.65,.48),vec3(.80,.79,.66),vec3(.47,.44,.57)),color(vec3(.72,.44,.41),vec3(.55,.65,.59),vec3(.31,.33,.45)),.0012); + // The wheel bottoms and rail head use precisely the same world coordinate. + float railY=.725; + float bridgeScale=${BRIDGE.worldWidth.toFixed(6)}/${BRIDGE.size.toFixed(1)}; + vec2 bp=vec2(fract(p.x/${BRIDGE.worldWidth.toFixed(6)}+travel*${BRIDGE.drift.toFixed(6)}), (p.y-(railY-${BRIDGE.rail.toFixed(1)}*bridgeScale))/${BRIDGE.worldWidth.toFixed(6)}); + vec4 bridge=texture2D(bridgeMap,bp); + bridge.a*=step(0.,bp.y)*step(bp.y,1.); + vec3 iron=color(vec3(.24,.16,.17),vec3(.23,.29,.27),vec3(.16,.16,.24)); + vec3 bridgeColor=mix(iron,vec3(.91,.55,.35),bridge.r*.24); + c=mix(c,bridgeColor,bridge.a); + // Train follows the camera. The world moves past it. + float headroom=max(.02,railScreen*framing-.012)*${(TRAIN.width/(TRAIN.rail-TRAIN.chimney[1])).toFixed(6)}; + float trainWidth=min(min(${TRAIN.worldWidth.toFixed(6)}*trainScale,aspect*framing*.90),headroom); + float centerX=mix(-.14,aspect*framing*.27,compact); + float trainLeft=clamp(centerX-trainWidth*.5,-aspect*framing*.475,aspect*framing*.475-trainWidth); + float modelScale=trainWidth/${TRAIN.width.toFixed(1)}; + float trainY=railY-${TRAIN.rail.toFixed(1)}*modelScale; + vec2 model=(p-vec2(trainLeft,trainY))/modelScale; + vec2 tp=model/vec2(${TRAIN.width.toFixed(1)},${TRAIN.height.toFixed(1)}); + vec4 train=texture2D(trainMap,tp); + train.a*=step(0.,tp.x)*step(tp.x,1.)*step(0.,tp.y)*step(tp.y,1.); + vec3 metal=color(vec3(.75,.57,.40),vec3(.64,.67,.57),vec3(.51,.57,.66)); + vec3 body=mix(iron*.82,metal,train.r*.86); + body=mix(body,color(vec3(1.,.84,.53),vec3(.99,.92,.72),vec3(1.,.77,.43)),train.g); + c=mix(c,body,train.a); + float distance=travel*${(BRIDGE.worldWidth*BRIDGE.drift).toFixed(8)}/modelScale; + float aa=max(.8,framing/(resolution.y*modelScale)); + if(model.x>0. && model.x<1024. && model.y>175. && model.y<234.){ + ${TRAIN.wheels.map(([x,r]) => `c=wheel(c,model,vec2(${x.toFixed(1)},${(TRAIN.rail-r).toFixed(1)}),${r.toFixed(1)},distance,iron,metal,aa);`).join('\n ')} + // One crank phase drives all three coupled wheels and the piston crosshead. + float a=distance/24.;vec2 crank=vec2(cos(a),sin(a))*13.; + vec2 a1=vec2(767.,208.)+crank,a2=vec2(821.,208.)+crank,a3=vec2(875.,208.)+crank; + float pistonY=195.; + vec2 crosshead=vec2(a3.x+sqrt(2500.-pow(pistonY-a3.y,2.)),pistonY); + float rod=min(segment(model,a1,a3),segment(model,a3,crosshead)); + c=mix(c,metal*.88,1.-smoothstep(1.2,2.8+aa*.25,rod)); + float joints=min(min(length(model-a1),length(model-a2)),length(model-a3)); + c=mix(c,metal,1.-smoothstep(2.,4.+aa*.2,joints)); + // A narrow contact shadow connects each grounded wheel to the rail. + float contact=(1.-smoothstep(.35,1.8,abs(model.y-232.)))*step(35.,model.x)*step(model.x,978.); + c=mix(c,iron*.78,contact*.45); + } + vec2 chimney=vec2(trainLeft,trainY)+vec2(${TRAIN.chimney[0].toFixed(1)},${TRAIN.chimney[1].toFixed(1)})*modelScale; + vec2 relative=(p-chimney)/(trainWidth/${TRAIN.worldWidth.toFixed(6)}); + float density=0.; + if(relative.x>-.80 && relative.x<.06 && relative.y>-.42 && relative.y<.025){ + for(int i=0;i<${STEAM_COUNT};i++){ + vec4 particle=steamParticles[i]; + if(particle.w>.001){ + vec2 delta=(relative-particle.xy)/vec2(particle.z*1.28,particle.z); + // Small curls break up the plume edge while preserving a soft, dense core. + vec2 curlField=vec2(noise(p*91.+vec2(time*.37,float(i)*7.3)),noise(p*79.-vec2(float(i)*3.9,time*.29)))-.5; + delta+=curlField*.38; + float d2=dot(delta,delta); + if(d2<6.){ + float curl=noise(p*125.+vec2(float(i)*7.3,time*.30)); + density+=exp(-d2*(1.6+curl*.8))*particle.w*(.68+curl*.5); + } + } + } + // Thin fresh jet, exactly anchored to the mouth; it joins the first puffs. + float jet=(1.-smoothstep(.002,.006,abs(relative.x)))*(1.-smoothstep(.004,.020,-relative.y))*step(relative.y,.0); + density+=jet*.58; + } + float smoke=(1.-exp(-density*1.45*steamAmount))*.84; + c=mix(c,color(vec3(1.,.96,.87),vec3(.99,.98,.92),vec3(.79,.83,.89)),smoke); + c=mix(c,bridgeColor,bridge.a*bridge.g); + // Fit the near cloud sea beneath the bridge in the original short masthead. + // Compress the drifting contours vertically, keeping every crest below the + // wheel/rail contact while leaving enough cloud body to read as a cloud bank. + float nearContour=cloud(vec2(p.x,1.),1.08,3.1,.29,81.2); + float cloudDrop=(.65-cloudLevel)*.09; + float cloudRoom=(1.-railScreen)*framing; + float nearGap=max(.014,.022+cloudDrop+.085*smoothstep(-.24,.24,-nearContour)); + float foregroundTop=railY+min(nearGap,max(.014,cloudRoom-.055)); + float shortHeader=1.-smoothstep(110.,200.,viewportHeight); + d=mix(min(cloud(p,1.08+cloudDrop,3.1,.29,81.2),p.y-railY-.014),p.y-foregroundTop,shortHeader); + c=paint(c,d,color(vec3(1.,.90,.73),vec3(.99,.94,.79),vec3(.60,.62,.69)),color(vec3(.94,.76,.60),vec3(.82,.83,.71),vec3(.43,.47,.59)),color(vec3(.75,.55,.51),vec3(.57,.69,.64),vec3(.28,.32,.45)),.001); + float frontContour=cloud(vec2(p.x,1.12),1.22,3.7,.39,113.6); + float frontGap=.082+cloudDrop+.065*smoothstep(-.24,.24,-frontContour); + d=mix(min(cloud(p,1.22+cloudDrop,3.7,.39,113.6),p.y-railY-.035),p.y-(railY+min(frontGap,max(.035,cloudRoom-.025))),shortHeader); + c=paint(c,d,color(vec3(.80,.58,.51),vec3(.75,.80,.69),vec3(.43,.46,.57)),color(vec3(.65,.45,.47),vec3(.58,.69,.63),vec3(.29,.34,.47)),color(vec3(.43,.34,.40),vec3(.40,.54,.52),vec3(.19,.25,.37)),.001); + float grain=texture2D(noiseMap,gl_FragCoord.xy/256.).r-.5; + c+=grain*.018; + float vignette=1.-.11*pow(length((st-.5)*vec2(1.05,.8)),1.4); + gl_FragColor=vec4(c*vignette,1.); +}`; diff --git a/apps/web/src/features/dashboard/header/cloudtrain/steam.ts b/apps/web/src/features/dashboard/header/cloudtrain/steam.ts new file mode 100644 index 0000000..52b1547 --- /dev/null +++ b/apps/web/src/features/dashboard/header/cloudtrain/steam.ts @@ -0,0 +1,47 @@ +// A small continuous simulation. A speed change accelerates existing steam; +// it never repositions the entire plume or emits a particle away from the stack. +export const STEAM_COUNT = 24; +const LIFE = 3.15, INTERVAL = .16; +const smooth = (a: number,b: number,x: number) => { const t=Math.max(0,Math.min(1,(x-a)/(b-a)));return t*t*(3-2*t); }; + +export function createSteam() { + const particles: ({ x: number; y: number; vx: number; vy: number; age: number; seed: number } | null)[] = Array(STEAM_COUNT).fill(null); + const data = new Float32Array(STEAM_COUNT * 4); + let accumulator=0, next=0, serial=0; + function spawn() { + const seed = serial++ * 2.399963; + particles[next] = { x:0,y:0,vx:-.018,vy:-.093,age:0,seed }; + next = (next + 1) % STEAM_COUNT; + } + function step(dt: number,worldSpeed: number) { + // Short substeps keep particle motion stable at 30fps or after a busy frame. + const count=Math.max(1,Math.ceil(dt*60)), h=dt/count; + for(let n=0;n=INTERVAL){accumulator-=INTERVAL;spawn();} + for(let i=0;i=LIFE){particles[i]=null;continue;} + const curl=smooth(.15,.9,p.age); + const wind=-(.065+worldSpeed*.96); + p.vx+=(wind-p.vx)*Math.min(1,h*1.8); + p.vy+=(-.044-p.vy)*Math.min(1,h*.75); + p.x+=(p.vx+Math.sin(p.age*3.7+p.seed)*.012*curl)*h; + p.y+=(p.vy+Math.cos(p.age*4.3+p.seed)*.009*curl)*h; + } + } + data.fill(0); + particles.forEach((p,i)=>{ + if(!p)return; + data[i*4]=p.x; data[i*4+1]=p.y; + data[i*4+2]=.0045+Math.pow(p.age,.92)*.016; + data[i*4+3]=smooth(0,.09,p.age)*(1-smooth(1.25,LIFE,p.age)); + }); + } + function reset(worldSpeed: number) { + particles.fill(null);accumulator=INTERVAL;next=0;serial=0; + // Start with an established plume rather than a sudden bare chimney. + for(let i=0;i<180;i++)step(1/60,worldSpeed); + } + return { data, step, reset }; +} diff --git a/apps/web/src/features/dashboard/header/cloudtrain/trainModel.ts b/apps/web/src/features/dashboard/header/cloudtrain/trainModel.ts new file mode 100644 index 0000000..f99d86f --- /dev/null +++ b/apps/web/src/features/dashboard/header/cloudtrain/trainModel.ts @@ -0,0 +1,138 @@ +// All contact points share a single rail datum. Texture channels encode material, +// so the same drawing works in sunset, morning and moonlight. +export const TRAIN = { + width: 1024, height: 256, rail: 232, worldWidth: .64, + chimney: [889, 75], + wheels: [ + [57, 11], [83, 11], [177, 11], [203, 11], + [282, 11], [308, 11], [402, 11], [428, 11], + [516, 14], [604, 14], [690, 16], + [767, 24], [821, 24], [875, 24], [937, 11], [963, 11], + ], +}; +export const BRIDGE = { size: 512, rail: 149, worldWidth: .62, drift: .19 }; + +const material = (light: number, second = 0) => `rgb(${light},${second},0)`; + +export function makeBridge() { + const sprite = document.createElement('canvas'); sprite.width = sprite.height = BRIDGE.size; + const context = sprite.getContext('2d'); if (!context) throw new Error('Canvas 2D unavailable'); + const c = context; + c.lineJoin = 'round'; c.lineCap = 'round'; + c.fillStyle = material(38); c.fillRect(0, 152, 512, 17); + c.fillStyle = material(92); c.fillRect(0, 153, 512, 2); + c.strokeStyle = material(42); c.lineWidth = 1.3; + for (let x = 0; x < 512; x += 28) { + c.beginPath(); c.moveTo(x, 157); c.lineTo(x + 14, 168); c.lineTo(x + 28, 157); c.stroke(); + } + // Far-side cables stay behind the train. + c.strokeStyle = material(42); c.lineWidth = 2.5; + c.beginPath(); c.moveTo(0, 57); c.quadraticCurveTo(256, 203, 512, 57); c.stroke(); + c.lineWidth = 1.1; c.beginPath(); c.moveTo(0, 64); c.quadraticCurveTo(256, 209, 512, 64); c.stroke(); + for (let x = 12; x < 512; x += 14) { + const t = x / 512, y = (1 - t) ** 2 * 57 + 2 * (1 - t) * t * 203 + t ** 2 * 57; + c.lineWidth = 1.05; c.beginPath(); c.moveTo(x, y); c.lineTo(x, 151); c.stroke(); + } + // Rail-head top is exactly y=149. Sleepers and the girder are underneath. + c.fillStyle = material(45); c.fillRect(0, BRIDGE.rail, 512, 3); + c.fillStyle = material(176); c.fillRect(0, BRIDGE.rail, 512, 1); + for (let x = 0; x < 512; x += 10) { c.fillStyle = material(72); c.fillRect(x, 151, 4, 2); } + // The green channel marks foreground tower faces. They occlude a passing train. + for (const x of [0, 512]) { + c.fillStyle = material(30, 255); + c.beginPath(); c.moveTo(x - 6, 47); c.lineTo(x + 6, 47); c.lineTo(x + 6, 147); + c.lineTo(x + 10, 162); c.lineTo(x + 9, 512); c.lineTo(x - 9, 512); c.lineTo(x - 10, 162); c.closePath(); c.fill(); + c.fillRect(x - 9, 45, 18, 5); c.fillRect(x - 3, 38, 6, 7); + c.fillStyle = material(112, 255); c.fillRect(x - 3, 52, 1.4, 91); + c.fillStyle = material(65, 255); c.fillRect(x - 3, 166, 2.2, 346); + for (let y = 64; y < 145; y += 18) { c.fillStyle = material(92, 255); c.fillRect(x + 1, y, 1.5, 1.5); } + } + return sprite; +} + +export function makeTrain() { + const sprite = document.createElement('canvas'); sprite.width = TRAIN.width; sprite.height = TRAIN.height; + const context = sprite.getContext('2d'); if (!context) throw new Error('Canvas 2D unavailable'); + const c = context; c.lineJoin = 'round'; c.lineCap = 'round'; + const box = (x: number,y: number,w: number,h: number,light: number,r=0,window=0) => { + c.fillStyle = material(light,window); c.beginPath(); c.roundRect(x,y,w,h,r); c.fill(); + }; + const line = (points: number[][],light: number,width=1) => { + c.strokeStyle = material(light); c.lineWidth = width; c.beginPath(); + points.forEach(([x,y],i) => i ? c.lineTo(x,y) : c.moveTo(x,y)); c.stroke(); + }; + function shadedBox(x: number,y: number,w: number,h: number,values: number[][],r: number) { + const g=c.createLinearGradient(x,y,x,y+h); + for (const [stop,value] of values) g.addColorStop(stop,material(value)); + c.fillStyle=g; c.beginPath(); c.roundRect(x,y,w,h,r); c.fill(); + } + function rivets(x1: number,x2: number,y: number,step=14) { + for(let x=x1;x span { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.dash-scene-slider output { color: var(--text-primary); font-variant-numeric: tabular-nums; } +.dash-scene-slider input[type='range'] { display: block; width: 100%; height: 24px; margin: 2px 0 0; accent-color: var(--accent); cursor: pointer; } +.dash-scene-dimension { display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary); } +.dash-scene-dimension input { width: 58px; padding: 3px 4px; border: 1px solid var(--rule); border-radius: 4px; background: var(--paper); color: var(--text-primary); text-align: right; font: inherit; font-variant-numeric: tabular-nums; } +.dash-scene-anchor { display: flex; gap: 8px; margin-top: 12px; } +.dash-scene-anchor button { border: 1px solid var(--rule); border-radius: 6px; padding: 7px 9px; background: var(--paper); color: var(--text-secondary); font: inherit; font-size: var(--fs-label); cursor: pointer; } +.dash-scene-anchor button[aria-pressed='true'] { border-color: var(--accent); color: var(--text-primary); background: var(--accent-soft); } +.dash-scene-slider input:focus-visible, .dash-scene-section summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 4px; } +.dash-scene-hint { margin: 8px 0 0; font-size: var(--fs-label); color: var(--text-tertiary); line-height: 1.5; } +.dash-scene-position-reset { display: flex; justify-content: flex-end; } +.dash-scene-position-reset button { border: 0; background: none; padding: 4px 0; color: var(--text-secondary); font-size: var(--fs-label); cursor: pointer; } +.dash-scene-footer { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--rule); font-size: var(--fs-label); color: var(--text-tertiary); } +.dash-scene-footer button { display: inline-flex; align-items: center; gap: 5px; padding: 5px 0; border: 0; background: none; color: var(--text-secondary); cursor: pointer; } +@media (max-width: 960px) { + .dash-header--scenery .dash-hero { justify-content: flex-start; } + :is(.dash-cloudtrain, .dash-springfield) { + left: calc(50% + 9px + var(--scene-x, 0%)); + width: calc((100% - 18px) * var(--train-width, 1)); + opacity: calc(var(--train-opacity, .84) * .4762); + mask-image: linear-gradient(90deg, transparent 26%, #000 74%), linear-gradient(180deg, transparent, #000 18%, #000 76%, transparent); + } + :is(.dash-cloudtrain, .dash-springfield)[data-anchor='bottom'] { mask-image: linear-gradient(90deg, transparent 26%, #000 74%), linear-gradient(180deg, transparent, #000 18%); } + .dash-cloudtrain__still { left: 50%; bottom: calc(60% - 8px + var(--train-offset, 0%)); max-height: calc(40% - var(--train-offset, 0%)); } +} +@media (max-width: 640px) { + .dash-scene-picker > span { display: none; } +} + +/* Spring uses the same placement contract as CloudTrain, with transparent pigment edges. */ +.dash-springfield { background: transparent; } +.dash-springfield__canvas, .dash-springfield__still { position: absolute; inset: 0; display: block; width: 100%; height: 100%; } +.dash-springfield__canvas { opacity: 0; } +.dash-springfield[data-ready='true'] .dash-springfield__canvas { opacity: 1; } +.dash-springfield[data-ready='true'] .dash-springfield__still { display: none; } +.dash-springfield[data-palette='dusk'] > :is(canvas, svg) { filter: sepia(.3) saturate(.85) hue-rotate(335deg) brightness(.95); } +.dash-springfield[data-palette='night'] > :is(canvas, svg) { filter: brightness(.56) saturate(.6) hue-rotate(12deg); } +.dash-scene-swatch[data-palette='spring-auto'] { background: linear-gradient(90deg, #a4c4a6 50%, #455f68 50%); } +.dash-scene-swatch[data-palette='spring-day'] { background: linear-gradient(135deg, #91bfc0, #e6b8bd); } +.dash-scene-swatch[data-palette='spring-dusk'] { background: linear-gradient(135deg, #acb398, #dbaaa4); } +.dash-scene-swatch[data-palette='spring-night'] { background: linear-gradient(135deg, #455f68, #8a7b8b); } +.dash-scene-toggle { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; color: var(--text-secondary); font-size: var(--fs-label); cursor: pointer; } +.dash-scene-toggle input { accent-color: var(--accent); width: 16px; height: 16px; } diff --git a/apps/web/src/features/dashboard/header/sceneSettings.ts b/apps/web/src/features/dashboard/header/sceneSettings.ts new file mode 100644 index 0000000..b304ee1 --- /dev/null +++ b/apps/web/src/features/dashboard/header/sceneSettings.ts @@ -0,0 +1,85 @@ +export type TrainPalette = 'sunset' | 'morning' | 'night' +export type SpringPalette = 'day' | 'dusk' | 'night' + +export const TRAIN_DEFAULTS = { + speed: .65, billow: 65, detail: 75, steam: 100, + trainScale: 100, width: 100, height: 80, opacity: 84, + railOffset: 0, cloudLevel: 65, sceneX: 0, sceneY: 0, sceneHeight: 80, +} +export type TrainSettings = typeof TRAIN_DEFAULTS +export type TrainSetting = keyof TrainSettings +export const TRAIN_RANGES: Record = { + speed: { min: 0, max: 1.6, step: .05 }, + billow: { min: 0, max: 100, step: 1 }, + detail: { min: 0, max: 100, step: 1 }, + steam: { min: 0, max: 150, step: 5 }, + trainScale: { min: 65, max: 170, step: 5 }, + width: { min: 10, max: 150, step: 1 }, + height: { min: 80, max: 240, step: 4 }, + opacity: { min: 20, max: 100, step: 1 }, + railOffset: { min: -12, max: 12, step: 1 }, + cloudLevel: { min: 0, max: 100, step: 1 }, + sceneX: { min: -40, max: 40, step: 1 }, + sceneY: { min: -50, max: 50, step: 1 }, + sceneHeight: { min: 32, max: 320, step: 1 }, +} +export const SPRING_NUMERIC_DEFAULTS = { + speed: .65, width: 100, height: 80, opacity: 84, + sceneX: 0, sceneY: 0, sceneHeight: 100, zoom: 100, clouds: 65, texture: 55, +} +export type SpringNumericSetting = keyof typeof SPRING_NUMERIC_DEFAULTS +export const SPRING_RANGES = { + speed: { min: 0, max: 1.5, step: .05 }, + width: TRAIN_RANGES.width, height: TRAIN_RANGES.height, opacity: TRAIN_RANGES.opacity, + sceneX: TRAIN_RANGES.sceneX, sceneY: TRAIN_RANGES.sceneY, sceneHeight: TRAIN_RANGES.sceneHeight, + zoom: { min: 75, max: 150, step: 5 }, clouds: { min: 0, max: 100, step: 5 }, texture: { min: 0, max: 100, step: 5 }, +} satisfies Record +export type SpringSettings = typeof SPRING_NUMERIC_DEFAULTS & { + palette: SpringPalette | 'auto'; paused: boolean; plane: boolean; sceneAnchor: 'top' | 'bottom' +} +export const SPRING_DEFAULTS: SpringSettings = { + ...SPRING_NUMERIC_DEFAULTS, palette: 'auto', paused: false, plane: true, sceneAnchor: 'bottom', +} +export type HeaderScene = TrainSettings & { + mode: 'cloudtrain' | 'spring' | 'desk'; palette: TrainPalette | 'auto'; paused: boolean + sceneAnchor: 'top' | 'bottom' + spring: SpringSettings +} +export type HeaderScenePatch = Omit, 'spring'> & { spring?: Partial } +export const DEFAULT_HEADER_SCENE: HeaderScene = { + ...TRAIN_DEFAULTS, mode: 'cloudtrain', palette: 'auto', paused: false, sceneAnchor: 'bottom', + spring: { ...SPRING_DEFAULTS }, +} + +function normalizeSpring(value: unknown): SpringSettings { + const saved = value && typeof value === 'object' ? value as Record : {} + const spring = { ...SPRING_DEFAULTS } + for (const key of Object.keys(SPRING_RANGES) as SpringNumericSetting[]) { + const number = saved[key], range = SPRING_RANGES[key] + if (typeof number === 'number' && Number.isFinite(number)) spring[key] = Math.min(range.max, Math.max(range.min, number)) + } + if (saved.palette === 'day' || saved.palette === 'dusk' || saved.palette === 'night') spring.palette = saved.palette + spring.paused = saved.paused === true + spring.plane = saved.plane !== false + spring.sceneAnchor = saved.sceneAnchor === 'top' ? 'top' : 'bottom' + return spring +} + +/** Older saved scenes acquire new defaults; untrusted storage cannot resize without bounds. */ +export function normalizeHeaderScene(value: unknown): HeaderScene { + const saved = value && typeof value === 'object' ? value as Record : {} + const scene = { ...DEFAULT_HEADER_SCENE } + scene.mode = saved.mode === 'desk' || saved.mode === 'spring' ? saved.mode : 'cloudtrain' + scene.spring = normalizeSpring(saved.spring) + if (saved.palette === 'auto' || saved.palette === 'sunset' || saved.palette === 'morning' || saved.palette === 'night') scene.palette = saved.palette + scene.paused = saved.paused === true + scene.sceneAnchor = saved.sceneAnchor === 'top' ? 'top' : 'bottom' + for (const key of Object.keys(TRAIN_RANGES) as TrainSetting[]) { + const number = saved[key], range = TRAIN_RANGES[key] + if (typeof number === 'number' && Number.isFinite(number)) scene[key] = Math.min(range.max, Math.max(range.min, number)) + } + // Old offsets were relative to the date area. Start the new bottom anchor + // at its edge instead of applying an offset that could hide the whole scene. + if (saved.sceneAnchor !== 'top' && saved.sceneAnchor !== 'bottom') scene.sceneY = 0 + return scene +} diff --git a/apps/web/src/features/dashboard/header/springfield/renderer.test.ts b/apps/web/src/features/dashboard/header/springfield/renderer.test.ts new file mode 100644 index 0000000..accf4b8 --- /dev/null +++ b/apps/web/src/features/dashboard/header/springfield/renderer.test.ts @@ -0,0 +1,31 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { createSpringRenderer } from './renderer' +import { SPRING_DEFAULTS } from '../sceneSettings' + +vi.mock('./terrain', () => ({ + WORLD: { height: 2240 }, random: () => () => .5, + createTerrain: () => ({ canvas: {}, trees: [] }), createCloud: () => ({}), + createPaper: () => { throw new Error('canvas allocation failed') }, + createEdge: vi.fn(), riverX: vi.fn(), riverWidth: vi.fn(), wrap: vi.fn(), +})) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +it('releases observers and visibility listening when the first frame cannot allocate its canvas', () => { + const resizeDisconnect = vi.fn(), intersectionDisconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = resizeDisconnect }) + vi.stubGlobal('IntersectionObserver', class { observe() {} disconnect = intersectionDisconnect }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const remove = vi.spyOn(document, 'removeEventListener') + const canvas = document.createElement('canvas') + vi.spyOn(canvas, 'getContext').mockReturnValue({} as CanvasRenderingContext2D) + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ width: 900, height: 100 } as DOMRect) + const ready = vi.fn() + const renderer = createSpringRenderer(canvas, { ...SPRING_DEFAULTS, palette: 'day' }, ready) + expect(ready).toHaveBeenLastCalledWith(false) + expect(resizeDisconnect).toHaveBeenCalledOnce() + expect(intersectionDisconnect).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledWith('visibilitychange', expect.any(Function)) + expect(() => renderer.update({ ...SPRING_DEFAULTS, palette: 'day' })).not.toThrow() + renderer.dispose() +}) diff --git a/apps/web/src/features/dashboard/header/springfield/renderer.ts b/apps/web/src/features/dashboard/header/springfield/renderer.ts new file mode 100644 index 0000000..ae964f2 --- /dev/null +++ b/apps/web/src/features/dashboard/header/springfield/renderer.ts @@ -0,0 +1,140 @@ +import { WORLD, createTerrain, createCloud, createPaper, createEdge, random, riverX, riverWidth, wrap } from './terrain' +import type { SpringSettings, SpringPalette } from '../sceneSettings' +export type SpringRenderOptions = Omit & { palette: SpringPalette } +export type SpringRenderer = { update: (options: SpringRenderOptions) => void; dispose: () => void } +let terrainCache: ReturnType | undefined +let spriteCache: ReturnType[] | undefined + +const TAU = Math.PI * 2 +const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v)) + +function planeShape(ctx: CanvasRenderingContext2D, shadow = false) { + ctx.beginPath(); ctx.moveTo(0, -27); ctx.bezierCurveTo(-4, -25, -4, -12, -3.5, -5) + ctx.lineTo(-33, 3); ctx.lineTo(-34, 8); ctx.lineTo(-3, 4); ctx.lineTo(-2, 20) + ctx.lineTo(-12, 24); ctx.lineTo(-12, 27); ctx.lineTo(0, 25); ctx.lineTo(12, 27); ctx.lineTo(12, 24) + ctx.lineTo(2, 20); ctx.lineTo(3, 4); ctx.lineTo(34, 8); ctx.lineTo(33, 3); ctx.lineTo(3.5, -5) + ctx.bezierCurveTo(4, -12, 4, -25, 0, -27); ctx.closePath() + ctx.fillStyle = shadow ? '#365e634f' : '#faf7e7'; ctx.fill() + if (shadow) return + ctx.strokeStyle = '#778f8d77'; ctx.lineWidth = .6; ctx.stroke() + ctx.strokeStyle = '#b7c5bd'; ctx.lineWidth = .8 + ctx.beginPath(); ctx.moveTo(-30, 5); ctx.lineTo(-5, 0); ctx.moveTo(30, 5); ctx.lineTo(5, 0); ctx.stroke() + ctx.fillStyle = '#bdbfa6'; ctx.beginPath(); ctx.moveTo(0, 8); ctx.lineTo(2, 21); ctx.lineTo(0, 25); ctx.closePath(); ctx.fill() + ctx.fillStyle = '#668c98'; ctx.beginPath(); ctx.ellipse(0, -10, 2.25, 5, 0, 0, TAU); ctx.fill() + ctx.fillStyle = '#9ebcc1'; ctx.beginPath(); ctx.ellipse(-.7, -12, .7, 2.5, 0, 0, TAU); ctx.fill() + ctx.strokeStyle = '#b77e66'; ctx.lineWidth = 1.3; ctx.beginPath(); ctx.moveTo(-3, -19); ctx.lineTo(3, -19); ctx.moveTo(-10, 24); ctx.lineTo(10, 24); ctx.stroke() +} + +export function createSpringRenderer(canvas: HTMLCanvasElement, initial: SpringRenderOptions, onReady: (ready: boolean) => void): SpringRenderer { + const context = canvas.getContext('2d') + if (!context) { onReady(false); return { update() {}, dispose() {} } } + const ctx = context + const terrain = terrainCache ??= createTerrain(), rng = random(98) + const sprites = spriteCache ??= [createCloud(16), createCloud(37), createCloud(88)] + const clouds = Array.from({length: 11}, (_, i) => ({ x: 140 + rng() * 850, y: i / 11 * WORLD.height, size: .55 + rng() * .5, sprite: i % 3, phase: rng() * TAU })) + const ripples = Array.from({length: 70}, () => ({ y: rng() * WORLD.height, cross: (rng() - .5) * .7, phase: rng() * TAU, length: 2 + rng() * 5 })) + let options = { ...initial }, width = 1, height = 1, dpr = 1 + let paper: HTMLCanvasElement | null = null, edge: HTMLCanvasElement | null = null + let travel = 0, elapsed = 0, frames = 0, frame = 0, lastTime = 0, disposed = false, visible = true + let resizeObserver: ResizeObserver | undefined, intersectionObserver: IntersectionObserver | undefined + const running = () => !disposed && !options.paused && options.speed > 0 && !document.hidden && visible + + function draw() { + if (disposed || !paper || !edge) return + const zoom = options.zoom / 100 + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + ctx.fillStyle = '#bad0b5'; ctx.fillRect(0, 0, width, height) + const scale = Math.max(height / 460 * zoom, height / 720) + const along = width / scale + const centerX = 718 + const centerY = wrap(670 - travel, WORLD.height) + const first = Math.floor((centerY - along / 2 - 230) / WORLD.height), last = Math.ceil((centerY + along / 2 + 230) / WORLD.height) + ctx.save(); ctx.translate(width / 2, height / 2); ctx.rotate(-Math.PI / 2) + ctx.scale(scale, scale); ctx.translate(-centerX, -centerY) + for (let tile = first; tile <= last; tile++) ctx.drawImage(terrain.canvas, 0, tile * WORLD.height) + // Small glints move with the river, while all paper and pigment noise stays fixed. + for (let tile = first; tile <= last; tile++) { + for (const p of ripples) { + const y = wrap(p.y + elapsed * 3, WORLD.height) + const x = riverX(y) + p.cross * riverWidth(y) + ctx.strokeStyle = `rgba(230,241,224,${.18 + .10 * Math.sin(elapsed * .7 + p.phase)})`; ctx.lineWidth = .9 + ctx.beginPath(); ctx.moveTo(x, y + tile * WORLD.height); ctx.lineTo(x + .5, y + p.length + tile * WORLD.height); ctx.stroke() + } + for (let i = 0; i < terrain.trees.length; i += 2) { + const t = terrain.trees[i], life = wrap(elapsed * .042 + i * .137, 1) + ctx.fillStyle = `rgba(248,216,219,${Math.sin(life * Math.PI) * .65})` + ctx.beginPath(); ctx.ellipse(t.x + life * 24 + Math.sin(life * 9 + i) * 6, t.y + life * 30 + tile * WORLD.height, .9, 1.4, life * 4, 0, TAU); ctx.fill() + } + } + const cloudPositions = clouds.map(c => ({ ...c, x: c.x + Math.sin(elapsed * .018 + c.phase) * 23, y: wrap(c.y - elapsed * 10, WORLD.height) })) + ctx.globalAlpha = options.clouds / 100 + for (let tile = first; tile <= last; tile++) for (const c of cloudPositions) { + ctx.drawImage(sprites[c.sprite].shadow, c.x - 150 * c.size + 29, c.y - 95 * c.size + 42 + tile * WORLD.height, 300 * c.size, 190 * c.size) + } + ctx.globalAlpha = 1; ctx.restore() + // The aircraft banks gently in screen space; its shadow shares the cloud light direction. + const px = width * .54 + Math.sin(elapsed * .13) * width * .13 + const py = height * .51 + Math.sin(elapsed * .085 + .4) * height * .035 + const bank = Math.cos(elapsed * .13) * .16 + const angle = -Math.PI / 2 + bank * .5 + const pscale = clamp(height / 170, .45, .95) * zoom + if (options.plane) { + ctx.save(); ctx.translate(px + 18 * pscale, py + (-18) * pscale); ctx.rotate(angle); ctx.scale(pscale, pscale); ctx.filter = 'blur(3px)'; planeShape(ctx, true); ctx.restore() + ctx.save(); ctx.translate(px, py); ctx.rotate(angle); ctx.scale(pscale, pscale); planeShape(ctx) + ctx.strokeStyle = '#eef0dd9c'; ctx.lineWidth = 1; ctx.beginPath(); ctx.ellipse(0, -27, 8, 1.5, Math.sin(elapsed * 60) * .15, 0, TAU); ctx.stroke(); ctx.restore() + } + ctx.save(); ctx.translate(width / 2, height / 2); ctx.rotate(-Math.PI / 2) + ctx.scale(scale, scale); ctx.translate(-centerX, -centerY) + ctx.globalAlpha = options.clouds / 100 * .93 + for (let tile = first; tile <= last; tile++) for (const c of cloudPositions) ctx.drawImage(sprites[c.sprite].cloud, c.x - 150 * c.size, c.y - 95 * c.size + tile * WORLD.height, 300 * c.size, 190 * c.size) + ctx.restore() + ctx.globalAlpha = options.texture / 100 * .8; ctx.drawImage(paper, 0, 0, width, height); ctx.globalAlpha = 1 + const sunlight = ctx.createLinearGradient(0, 0, width, height); sunlight.addColorStop(0, '#fff3ce14'); sunlight.addColorStop(1, '#d3ded700') + ctx.fillStyle = sunlight; ctx.fillRect(0, 0, width, height) + ctx.globalCompositeOperation = 'destination-out'; ctx.drawImage(edge, 0, 0, width, height); ctx.globalCompositeOperation = 'source-over' + frames++ + if (import.meta.env.DEV) { canvas.dataset.frame = String(frames); canvas.dataset.time = elapsed.toFixed(3); canvas.dataset.running = String(running()); canvas.dataset.view = 'header' } + } + function tick(now: number) { + frame = 0 + if (!running()) { lastTime = 0; draw(); return } + const delta = lastTime ? now - lastTime : 1000 / 30 + if (delta >= 1000 / 30 - .5) { + const dt = Math.min(delta / 1000, .08); elapsed += dt * options.speed; travel += dt * options.speed * 28 + lastTime = now; draw() + } + frame = requestAnimationFrame(tick) + } + function reconcile() { + cancelAnimationFrame(frame); frame = 0; lastTime = 0 + draw() + if (running()) frame = requestAnimationFrame(tick) + } + function resize() { + if (disposed) return + try { + const rect = canvas.getBoundingClientRect() + if (!rect.width || !rect.height) return + width = rect.width; height = rect.height + dpr = Math.max(.75, Math.min(devicePixelRatio || 1, 1.5, Math.sqrt(1000000 / (width * height)))) + canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr) + paper = createPaper(canvas.width, canvas.height); edge = createEdge(Math.ceil(width), Math.ceil(height), 'header') + reconcile(); onReady(true) + } catch { dispose(); onReady(false) } + } + function dispose() { + disposed = true; cancelAnimationFrame(frame); resizeObserver?.disconnect(); intersectionObserver?.disconnect() + document.removeEventListener('visibilitychange', reconcile); paper = null; edge = null + } + try { + resizeObserver = new ResizeObserver(resize); resizeObserver.observe(canvas) + intersectionObserver = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; reconcile() }); intersectionObserver.observe(canvas) + document.addEventListener('visibilitychange', reconcile) + resize() + } catch { dispose(); onReady(false) } + return { + update(next) { if (!disposed) { options = { ...next }; reconcile() } }, + dispose + } +} diff --git a/apps/web/src/features/dashboard/header/springfield/terrain.ts b/apps/web/src/features/dashboard/header/springfield/terrain.ts new file mode 100644 index 0000000..faad045 --- /dev/null +++ b/apps/web/src/features/dashboard/header/springfield/terrain.ts @@ -0,0 +1,205 @@ +// Original procedural artwork. Expensive brushwork is cached once, never per frame. +export const WORLD = { width: 1120, height: 2240 } +const TAU = Math.PI * 2 +export function random(seed: number) { return () => { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t ^= t + Math.imul(t ^ t >>> 7, 61 | t); return ((t ^ t >>> 14) >>> 0) / 4294967296 } } +export const wrap = (v: number, period: number) => ((v % period) + period) % period +export function makeCanvas(w: number, h: number) { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; return canvas } +export function riverX(y: number) { const a = y / WORLD.height * TAU; return 748 + Math.sin(a) * 27 + Math.sin(a * 3 + .7) * 11 } +export function riverWidth(y: number) { return 78 + 8 * Math.sin(y / WORLD.height * TAU * 2) } +function polygon(ctx: CanvasRenderingContext2D, points: number[][]) { ctx.beginPath(); points.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath() } +function ellipse(ctx: CanvasRenderingContext2D, x: number, y: number, rx: number, ry: number, color: string | CanvasGradient) { ctx.fillStyle = color; ctx.beginPath(); ctx.ellipse(x, y, rx, ry, 0, 0, TAU); ctx.fill() } + +function field(ctx: CanvasRenderingContext2D, rng: () => number, x: number, y: number, w: number, h: number) { + const colors = ['#91bfc0', '#7cabb8', '#a4c4a6', '#8fb69e', '#a2c68e', '#81b1ab', '#9dc4c5', '#a6c8a3', '#afc291', '#8ebbc3', '#b5b48b'] + const tone = Math.floor(rng() * colors.length) + const pts = [[x + 2, y], [x + w - 2, y + rng() * 4], [x + w - 3, y + h], [x + 2, y + h - rng() * 3]] + ctx.save(); polygon(ctx, pts); ctx.fillStyle = colors[tone]; ctx.fill(); ctx.clip() + const wash = ctx.createLinearGradient(x, y, x + w * .8, y + h) + wash.addColorStop(0, '#f2ecc33a'); wash.addColorStop(.45, '#f5f7dd08'); wash.addColorStop(1, '#426d7718') + ctx.fillStyle = wash; ctx.fillRect(x, y, w, h) + // Long, translucent strokes leave the quiet irregularity of a wet brush. + for (let i = 0; i < 16; i++) { + ctx.strokeStyle = i % 3 ? '#eef2cc13' : '#487b7e0c'; ctx.lineWidth = 1 + rng() * 5 + const xx = x + rng() * w + ctx.beginPath(); ctx.moveTo(xx, y - 5); ctx.bezierCurveTo(xx + 3, y + h * .4, xx - 2, y + h * .6, xx + rng() * 3, y + h + 6); ctx.stroke() + } + for (let i = 0; i < w * h / 90; i++) { + const px = x + rng() * w, py = y + rng() * h + ellipse(ctx, px, py, .35 + rng() * .75, .3 + rng() * 1.6, rng() > .5 ? '#f5f3d12a' : '#3d7e7210') + } + if (tone === 4 || tone === 7 || tone === 10) { + ctx.lineWidth = .6; ctx.strokeStyle = tone === 10 ? '#777f631c' : '#587f5920' + for (let xx = x + 5; xx < x + w; xx += 5.5) { + ctx.beginPath(); ctx.moveTo(xx, y); ctx.lineTo(xx + 1.5, y + h); ctx.stroke() + } + } else if (rng() > .4) { + ctx.fillStyle = '#e2edd727' + for (let i = 0; i < 5; i++) ctx.fillRect(x + 5 + rng() * (w - 10), y + 10 + rng() * (h - 20), .7, 4 + rng() * 7) + } + ctx.restore() + polygon(ctx, pts); ctx.lineWidth = 2.8; ctx.strokeStyle = '#b4c992'; ctx.stroke() + ctx.beginPath(); ctx.moveTo(x + 2, y + h); ctx.lineTo(x + 2, y); ctx.lineTo(x + w - 2, y + 1); ctx.lineWidth = 1; ctx.strokeStyle = '#dce0bca0'; ctx.stroke() + ctx.beginPath(); ctx.moveTo(x + w - 1, y); ctx.lineTo(x + w - 1, y + h); ctx.lineWidth = .8; ctx.strokeStyle = '#68958a65'; ctx.stroke() +} + +function riverPath(ctx: CanvasRenderingContext2D, expand = 0) { + ctx.beginPath() + for (let y = 0; y <= WORLD.height; y += 8) { const x = riverX(y) - riverWidth(y) / 2 - expand; if (!y) ctx.moveTo(x, y); else ctx.lineTo(x, y) } + for (let y = WORLD.height; y >= 0; y -= 8) ctx.lineTo(riverX(y) + riverWidth(y) / 2 + expand, y) + ctx.closePath() +} + +function treeSprite(seed: number, dark = false) { + const rng = random(seed), c = makeCanvas(128, 144), ctx = c.getContext('2d')! + if (dark) { + ctx.filter = 'blur(4px)'; ellipse(ctx, 72, 86, 30, 22, '#3c5e5440'); ellipse(ctx, 79, 99, 14, 18, '#3c5e5428'); return c + } + ctx.strokeStyle = '#73735f'; ctx.lineWidth = 2.1; ctx.beginPath(); ctx.moveTo(64, 96); ctx.lineTo(63, 62); ctx.moveTo(63, 78); ctx.lineTo(44, 53); ctx.moveTo(63, 71); ctx.lineTo(78, 49); ctx.stroke() + const petals = ['#d49aa2', '#dda5ac', '#e7b7bc', '#e9c0c1', '#edc8c8', '#f1d2ca', '#f0c8c7'] + const clumps = [] + for (let i = 0; i < 13; i++) { const a = i / 13 * TAU; clumps.push({x: 62 + Math.cos(a) * (16 + rng() * 6), y: 61 + Math.sin(a) * (16 + rng() * 4), r: 10 + rng() * 6}) } + clumps.push({ x: 62, y: 61, r: 21 }) + ctx.filter = 'blur(1.3px)' + for (const p of clumps) ellipse(ctx, p.x, p.y + 4, p.r + 1, p.r * .9, '#c98c9d40') + ctx.filter = 'none' + for (const p of clumps) { + const g = ctx.createRadialGradient(p.x - 4, p.y - 5, 0, p.x, p.y, p.r) + g.addColorStop(0, '#f0cac8'); g.addColorStop(.7, '#e6b3bd'); g.addColorStop(1, '#d59caaad') + ellipse(ctx, p.x, p.y, p.r, p.r * .9, g) + } + // Individual pigment blooms, fixed in the tree's texture. + for (let i = 0; i < 1450; i++) { + const a = rng() * TAU, r = Math.sqrt(rng()) * 33, x = 62 + Math.cos(a) * r, y = 61 + Math.sin(a) * r * .91 + if (!clumps.some(p => (x - p.x) ** 2 + (y - p.y) ** 2 < p.r ** 2)) continue + const shade = Math.max(0, Math.min(6, Math.floor(3 - (x - 62 + y - 61) / 25 + rng() * 3))) + ctx.globalAlpha = .17 + rng() * .42; ellipse(ctx, x, y, .5 + rng() * 1.1, .4 + rng() * .9, petals[shade]) + } + ctx.globalAlpha = 1 + return c +} + +export function createTerrain(seed = 27) { + const rng = random(seed), canvas = makeCanvas(WORLD.width, WORLD.height), ctx = canvas.getContext('2d')! + ctx.fillStyle = '#b6ca9e'; ctx.fillRect(0, 0, canvas.width, canvas.height) + let x = -5 + while (x < WORLD.width) { + const w = 35 + rng() * 36 + // The top and bottom share a boundary, so the painted world can tile vertically. + const count = 12 + Math.floor(rng() * 5), step = WORLD.height / count + for (let j = 0; j < count; j++) field(ctx, rng, x, j * step, w, step) + x += w + } + // Small irrigation channels carry a little of the sky between the paddies. + for (const yy of [185, 580, 1110, 1510, 1960]) { + ctx.fillStyle = '#729e9c7a'; ctx.fillRect(0, yy - 1, WORLD.width, 3) + ctx.fillStyle = '#d7ddbaaf'; ctx.fillRect(0, yy - 3, WORLD.width, 1) + } + ctx.fillStyle = '#b3c794'; ctx.fillRect(315, 0, 25, WORLD.height) + ctx.fillStyle = '#d2ceb3'; ctx.fillRect(321, 0, 13, WORLD.height) + ctx.fillStyle = '#e1debf'; ctx.fillRect(322, 0, 1, WORLD.height) + ctx.setLineDash([7, 15]); ctx.strokeStyle = '#eeead570'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(328, 0); ctx.lineTo(328, WORLD.height); ctx.stroke(); ctx.setLineDash([]) + riverPath(ctx, 10); ctx.fillStyle = '#a8c28e'; ctx.fill() + riverPath(ctx, 5); ctx.fillStyle = '#d0d2a2'; ctx.fill() + riverPath(ctx); ctx.fillStyle = '#709fb6'; ctx.fill() + ctx.save(); riverPath(ctx); ctx.clip() + for (let i = 0; i < 14; i++) { + const t = i / 13 + ctx.strokeStyle = i % 4 === 0 ? '#d7e5dc20' : '#426d9b13'; ctx.lineWidth = 3 + rng() * 8 + ctx.beginPath() + for (let y = 0; y <= WORLD.height; y += 8) { + const xx = riverX(y) + (t - .5) * riverWidth(y) + Math.sin(y * .03 + i) * 3 + if (!y) ctx.moveTo(xx, y); else ctx.lineTo(xx, y) + } + ctx.stroke() + } + ctx.restore() + for (const y of [365, 1080, 1820]) { + ctx.fillStyle = '#c2cd9a'; ctx.fillRect(0, y - 7, WORLD.width, 15) + ctx.fillStyle = '#ddd5b6'; ctx.fillRect(0, y - 3, WORLD.width, 7) + const rx = riverX(y), rw = riverWidth(y) + ctx.fillStyle = '#446b7150'; ctx.fillRect(rx - rw / 2 - 6, y + 6, rw + 15, 6) + ctx.fillStyle = '#b49775'; ctx.fillRect(rx - rw / 2 - 8, y - 6, rw + 16, 12) + ctx.fillStyle = '#d6b996'; ctx.fillRect(rx - rw / 2 - 8, y - 6, rw + 16, 3) + ctx.strokeStyle = '#887e6450'; ctx.lineWidth = .8 + for (let bx = rx - rw / 2 - 4; bx < rx + rw / 2 + 8; bx += 5) { ctx.beginPath(); ctx.moveTo(bx, y - 2); ctx.lineTo(bx, y + 5); ctx.stroke() } + } + const treeImages = Array.from({ length: 7 }, (_, i) => treeSprite(210 + i * 37)) + const shade = treeSprite(1, true), trees = [] + for (let i = 0; i < 46; i++) { + const y = 50 + rng() * (WORLD.height - 100) + const cluster = i < 25, x = cluster ? riverX(y) - 75 - rng() * 92 : 55 + rng() * (WORLD.width - 110) + if (Math.abs(x - riverX(y)) < 65 || Math.abs(x - 328) < 21) continue + trees.push({ x, y, scale: .46 + rng() * .26, sprite: i % 7 }) + } + // A few deliberate clusters by the bridges make recognisable landmarks. + for (const yy of [320, 1005, 1740]) for (let i = 0; i < 5; i++) trees.push({ x: riverX(yy) - 83 - (i % 2) * 35, y: yy + i * 23, scale: .55 + rng() * .15, sprite: i }) + for (const tree of trees) { + const s = tree.scale + ctx.drawImage(shade, tree.x - 64 * s + 16, tree.y - 66 * s + 17, 128 * s, 144 * s) + // Petals scatter predominantly downwind rather than forming a regular halo. + for (let j = 0; j < 48; j++) { + const a = rng() * TAU, r = 17 + rng() * 22 + ellipse(ctx, tree.x + Math.cos(a) * r * s + 8, tree.y + Math.sin(a) * r * s + 14, .4 + rng() * .7, .5 + rng() * .9, ['#f2dfcfb0', '#f1d2d1b0', '#e8b5c097'][j % 3]) + } + } + trees.sort((a, b) => a.y - b.y) + for (const tree of trees) ctx.drawImage(treeImages[tree.sprite], tree.x - 64 * tree.scale, tree.y - 66 * tree.scale, 128 * tree.scale, 144 * tree.scale) + // Sparse, tiny field markers add scale without competing with the trees. + for (let i = 0; i < 85; i++) { + const x = rng() * WORLD.width, y = rng() * WORLD.height + if (Math.abs(x - riverX(y)) < 58) continue + ellipse(ctx, x + 2, y + 3, 1.4, 2.5, '#50796529'); ellipse(ctx, x, y, .9, 1.5, '#e1e0bb') + } + return { canvas, trees } +} + +export function createCloud(seed: number) { + const rng = random(seed), c = makeCanvas(300, 190), ctx = c.getContext('2d')! + const clumps = [{x:145,y:97,r:47},{x:100,y:100,r:32},{x:184,y:103,r:37},{x:127,y:66,r:32},{x:166,y:64,r:37},{x:207,y:99,r:23},{x:77,y:111,r:19}] + ctx.filter = 'blur(3px)' + for (const p of clumps) ellipse(ctx, p.x, p.y + 7, p.r * 1.1, p.r * .78, '#d2e1db70') + ctx.filter = 'blur(.7px)' + for (const p of clumps) { + const g = ctx.createRadialGradient(p.x - 7, p.y - 10, 3, p.x, p.y, p.r) + g.addColorStop(0, '#fffef0f5'); g.addColorStop(.68, '#f8faeef0'); g.addColorStop(1, '#e4eee3b0') + ellipse(ctx, p.x, p.y, p.r, p.r * .8, g) + } + ctx.filter = 'none' + for (let i = 0; i < 1500; i++) { + const x = 48 + rng() * 190, y = 26 + rng() * 115 + if (!clumps.some(p => ((x - p.x) / p.r) ** 2 + ((y - p.y) / (p.r * .8)) ** 2 < 1)) continue + ellipse(ctx, x, y, .5 + rng(), .5 + rng(), i % 2 ? '#ffffff0c' : '#96b7b20b') + } + const shadow = makeCanvas(300, 190), sc = shadow.getContext('2d')! + sc.filter = 'blur(13px)'; sc.drawImage(c, 0, 0); sc.globalCompositeOperation = 'source-in'; sc.fillStyle = '#355a6249'; sc.fillRect(0, 0, 300, 190) + return { cloud: c, shadow } +} + +export function createPaper(w: number, h: number, seed = 51) { + const c = makeCanvas(w, h), ctx = c.getContext('2d')!, rng = random(seed) + const data = ctx.createImageData(w, h) + for (let i = 0; i < data.data.length; i += 4) { + const bright = rng() > .5 + data.data[i] = bright ? 252 : 69; data.data[i + 1] = bright ? 248 : 86; data.data[i + 2] = bright ? 225 : 74; data.data[i + 3] = Math.floor(rng() * 29) + } + ctx.putImageData(data, 0, 0) + return c +} + +export function createEdge(w: number, h: number, mode: 'header' | 'postcard') { + const c = makeCanvas(w, h), ctx = c.getContext('2d')!, rng = random(91) + const depth = mode === 'header' ? Math.min(12, h * .14) : Math.min(24, w * .038) + const data = ctx.createImageData(w, h) + const smooth = (v: number) => { const t = Math.max(0, Math.min(1, v)); return t * t * (3 - 2 * t) } + for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) { + const d = mode === 'header' ? y : Math.min(x, y, w - 1 - x, h - 1 - y) + if (d > depth * 1.7) continue + const along = d === x || d === w - 1 - x ? y : x + const boundary = depth * (.84 + Math.sin(along * .047) * .15 + Math.sin(along * .139 + 2) * .08) + const grain = rng(), a = 1 - smooth((d - boundary * .24 + (grain - .5) * depth * .35) / (boundary * .78)) + const index = (y * w + x) * 4 + data.data[index] = 252; data.data[index + 1] = 251; data.data[index + 2] = 248; data.data[index + 3] = Math.round(a * 255) + } + ctx.putImageData(data, 0, 0) + return c +} diff --git a/apps/web/src/features/dashboard/header/useHeaderScene.ts b/apps/web/src/features/dashboard/header/useHeaderScene.ts new file mode 100644 index 0000000..41bdc87 --- /dev/null +++ b/apps/web/src/features/dashboard/header/useHeaderScene.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { DEFAULT_HEADER_SCENE, normalizeHeaderScene, type HeaderScene, type HeaderScenePatch } from './sceneSettings' + +export type { HeaderScene } from './sceneSettings' +export const HEADER_SCENE_KEY = 'focusgo.dashboard.headerScene' + +export function readHeaderScene(): HeaderScene { + try { + return normalizeHeaderScene(JSON.parse(localStorage.getItem(HEADER_SCENE_KEY) ?? 'null')) + } catch { return { ...DEFAULT_HEADER_SCENE } } +} + +/** Like desk props, this is a device-local visual preference, not workspace data. */ +export function useHeaderScene() { + const [scene, setScene] = useState(readHeaderScene) + useEffect(() => { + const onStorage = (event: StorageEvent) => { if (event.key === HEADER_SCENE_KEY || event.key === null) setScene(readHeaderScene()) } + window.addEventListener('storage', onStorage) + return () => window.removeEventListener('storage', onStorage) + }, []) + const update = (patch: HeaderScenePatch) => { + setScene(current => { + const next = normalizeHeaderScene({ ...current, ...patch, spring: { ...current.spring, ...patch.spring } }) + try { localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify(next)) } catch { /* Keep the usable in-memory choice. */ } + return next + }) + } + return [scene, update] as const +} + +type Connection = EventTarget & { saveData?: boolean } +export function useSceneReducedMotion() { + const read = () => document.documentElement.dataset.motion === 'reduce' + || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false) + || Boolean((navigator as Navigator & { connection?: Connection }).connection?.saveData) + const [reduced, setReduced] = useState(read) + useEffect(() => { + const media = window.matchMedia?.('(prefers-reduced-motion: reduce)') + const connection = (navigator as Navigator & { connection?: Connection }).connection + const sync = () => setReduced(read()) + const observer = new MutationObserver(sync) + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-motion'] }) + media?.addEventListener('change', sync); connection?.addEventListener('change', sync) + return () => { observer.disconnect(); media?.removeEventListener('change', sync); connection?.removeEventListener('change', sync) } + }, []) + return reduced +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 3309f4a..e5ec0f4 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -11100,6 +11100,14 @@ textarea:focus-visible, flex-direction: column; } + @supports (zoom: 1) { + .focus-shell__scale-wrap { + /* A column flex child already resolves percentage widths through zoom. + Dividing again clips the header and controls at tablet widths. */ + width: 100%; + } + } + .focus-shell__main { min-height: auto; flex: 1 1 auto;