diff --git a/apps/web/src/features/dashboard/DashboardHeader.tsx b/apps/web/src/features/dashboard/DashboardHeader.tsx index aee32bc..12f1859 100644 --- a/apps/web/src/features/dashboard/DashboardHeader.tsx +++ b/apps/web/src/features/dashboard/DashboardHeader.tsx @@ -149,13 +149,14 @@ const DashboardHeader = ({ } const showLayoutActions = page !== 'news' && page !== 'custom' + const panorama = scenery && activeSettings.framing === 'panorama' // A masthead to read (the date on the left, the day's line on the right as // 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 === 'cloudtrain' && } {scene.mode === 'spring' && }
80 ? { minHeight: activeSettings.height } : undefined}>
diff --git a/apps/web/src/features/dashboard/hanging-notes/controller.ts b/apps/web/src/features/dashboard/hanging-notes/controller.ts index 73f7af6..32b469e 100644 --- a/apps/web/src/features/dashboard/hanging-notes/controller.ts +++ b/apps/web/src/features/dashboard/hanging-notes/controller.ts @@ -1074,10 +1074,10 @@ export function createHangingNotes(root: HTMLElement, initial: HangingNote[], on const header = dashboard.querySelector('.dash-header'); if (header) observer.observe(header); - // Quote swaps replace the figure; header size alone can remain unchanged. + // Quotes and scenery framing can move the hanger without resizing the header. const headerObserver = new MutationObserver(() => measure()); if (header) - headerObserver.observe(header, { childList: true, subtree: true, characterData: true }); + headerObserver.observe(header, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['data-scene-framing'] }); listen(window, 'resize', measure); void document.fonts?.ready.then(() => { if (!disposed) measure(); }); diff --git a/apps/web/src/features/dashboard/header/CloudTrain.tsx b/apps/web/src/features/dashboard/header/CloudTrain.tsx index 2488323..31304dc 100644 --- a/apps/web/src/features/dashboard/header/CloudTrain.tsx +++ b/apps/web/src/features/dashboard/header/CloudTrain.tsx @@ -2,7 +2,7 @@ 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' }) { +export default function CloudTrain({ palette, paused, settings = TRAIN_DEFAULTS, anchor = 'bottom', panorama = false }: { palette: TrainPalette; paused: boolean; settings?: TrainSettings; anchor?: 'top' | 'bottom'; panorama?: boolean }) { const canvas = useRef(null) const renderer = useRef(null) const options = useRef({ ...settings, palette, paused }) @@ -27,7 +27,7 @@ export default function CloudTrain({ palette, paused, settings = TRAIN_DEFAULTS, '--scene-x': `${settings.sceneX}%`, '--scene-y': `${settings.sceneY}%`, '--scene-height': `${settings.sceneHeight}px`, } as CSSProperties}> - + diff --git a/apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx b/apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx index b439407..4d6bf3b 100644 --- a/apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx +++ b/apps/web/src/features/dashboard/header/DashboardHeader.scene.test.tsx @@ -23,8 +23,34 @@ const onSetPage = vi.fn(), onToggleLayoutEdit = vi.fn() const renderHeader = () => render() describe('dashboard header scene integration', () => { + it('fills legacy scenes by default and restores saved placement when opting into free placement', () => { + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ height: 180, width: 82, sceneHeight: 110, sceneX: 5, spring: { height: 160, width: 65, sceneHeight: 150 } })) + const first = renderHeader() + expect(first.container.querySelector('.dash-header')).toHaveAttribute('data-scene-framing', 'panorama') + expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '110px' }) + expect(first.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '180px' }) + fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + expect(screen.queryByRole('slider', { name: '动画宽度' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /自由摆放/ })) + expect(screen.getByRole('slider', { name: '动画宽度' })).toHaveValue('82') + expect(screen.getByRole('slider', { name: '动画高度' })).toHaveValue('110') + expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '110px' }) + fireEvent.click(screen.getByRole('button', { name: '春日田野' })) + expect(first.container.querySelector('.dash-header')).toHaveAttribute('data-scene-framing', 'panorama') + fireEvent.click(screen.getByRole('button', { name: /自由摆放/ })) + expect(screen.getByRole('slider', { name: '动画宽度' })).toHaveValue('65') + expect(first.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '150px' }) + expect(first.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '160px' }) + fireEvent.click(screen.getByRole('button', { name: /全景铺满/ })) + first.unmount() + const second = renderHeader() + expect(second.container.querySelector('.dash-header')).toHaveAttribute('data-scene-framing', 'panorama') + expect(second.container.querySelector('.dash-header')).toHaveStyle({ minHeight: '150px' }) + expect(second.container.querySelector('.dash-hero')).toHaveStyle({ minHeight: '160px' }) + expect(readHeaderScene()).toMatchObject({ framing: 'custom', width: 82, sceneHeight: 110, spring: { framing: 'panorama', width: 65, sceneHeight: 150 } }) + }) 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 } })) + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, mode: 'spring', spring: { ...SPRING_DEFAULTS, framing: 'custom', width: 150, sceneHeight: 32 } })) renderHeader() fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) const width = screen.getByRole('spinbutton', { name: '输入动画宽度' }) @@ -36,13 +62,14 @@ describe('dashboard header scene integration', () => { 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 })) + localStorage.setItem(HEADER_SCENE_KEY, JSON.stringify({ ...DEFAULT_HEADER_SCENE, framing: 'custom', 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.click(screen.getByRole('button', { name: /自由摆放/ })) fireEvent.change(screen.getByRole('spinbutton', { name: '输入动画宽度' }), { target: { value: '65' } }) fireEvent.blur(screen.getByRole('spinbutton', { name: '输入动画宽度' })) fireEvent.change(screen.getByRole('slider', { name: '动画高度' }), { target: { value: '220' } }) @@ -124,6 +151,7 @@ describe('dashboard header scene integration', () => { it('previews custom dimensions, preserves them on reload and restores the original layout', () => { const first = renderHeader() fireEvent.click(screen.getByRole('button', { name: '选择顶栏场景' })) + 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' } }) diff --git a/apps/web/src/features/dashboard/header/HeaderSceneControls.tsx b/apps/web/src/features/dashboard/header/HeaderSceneControls.tsx index 519ba88..5c257bb 100644 --- a/apps/web/src/features/dashboard/header/HeaderSceneControls.tsx +++ b/apps/web/src/features/dashboard/header/HeaderSceneControls.tsx @@ -13,6 +13,7 @@ export default function HeaderSceneControls({ scene, update, reduced }: { const id = useId() const spring = scene.mode === 'spring' const active = spring ? scene.spring : scene + const custom = active.framing === 'custom' // 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 } @@ -76,8 +77,17 @@ export default function HeaderSceneControls({ scene, update, reduced }: { {([['auto', zh ? '跟随主题' : 'Match theme'], ['day', zh ? '春日' : 'Daylight'], ['dusk', zh ? '晚霞' : 'Dusk'], ['night', zh ? '月色' : 'Moonlight']] as const).map(([value, label]) =>
} +
+ + +
- {zh ? '尺寸与位置' : 'Size & placement'} + {zh ? (custom ? '尺寸与位置' : '画面细节') : (custom ? 'Size & placement' : 'Scene details')} + {custom && <>
@@ -88,10 +98,11 @@ export default function HeaderSceneControls({ scene, update, reduced }: { {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.'}

+ {custom && slider('height', zh ? '顶栏高度' : 'Header height')} +

{zh ? (custom ? '尺寸与位置会独立保留,随时可以切回全景。' : '保持顶栏原有高度,风景铺满背景,文字下方轻柔留白。') : (custom ? 'Your placement is kept when switching back to panorama.' : 'Scenery fills the existing header without changing its height, with soft space beneath the text.')}

{zh ? (spring ? '田野与动画' : '云海与动画') : (spring ? 'Fields & motion' : 'Clouds & motion')} @@ -105,7 +116,7 @@ export default function HeaderSceneControls({ scene, update, reduced }: {
{zh ? '各场景分别保存在此设备' : 'Saved separately for each scene'} - +
} diff --git a/apps/web/src/features/dashboard/header/dashboard-header.css b/apps/web/src/features/dashboard/header/dashboard-header.css index 3402109..b5bd94e 100644 --- a/apps/web/src/features/dashboard/header/dashboard-header.css +++ b/apps/web/src/features/dashboard/header/dashboard-header.css @@ -738,3 +738,87 @@ html[data-motion='reduce'] .dash-hero__quote-author { .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; } + +/* Panorama is a background-only treatment. Keep the original hero, toolbar, + spacing and responsive layout so changing framing never adds header height. + Only scenery is clipped; notes keep the Dashboard-sized drag surface. */ +.dash-header[data-scene-framing='panorama'] .dash-cloudtrain-frame { + inset-inline: 18px var(--dash-header-inset-end, 24px); +} +.dash-header[data-scene-framing='panorama'] :is(.dash-cloudtrain, .dash-springfield) { + inset: 0; + width: 100%; + height: 100%; + transform: none; + opacity: var(--train-opacity, .84); + mask-image: linear-gradient(90deg, transparent, #000 1.5%, #000 98.5%, transparent), linear-gradient(180deg, transparent, #000 4%, #000 96%, transparent); +} +.dash-header[data-scene-framing='panorama'] .dash-cloudtrain__still { + inset: 0; + width: 100%; + height: 100%; + max-height: none; + transform: none; +} +.dash-header[data-scene-framing='panorama'] :is(.dash-hero__day, .dash-hero__quote, .dash-toolbar) { + position: relative; + isolation: isolate; + z-index: 0; +} +.dash-header[data-scene-framing='panorama'] :is(.dash-hero__day, .dash-hero__quote)::before { + content: ''; + position: absolute; + z-index: -1; + inset: -16px; + pointer-events: none; + background: radial-gradient(ellipse at 12% 50%, color-mix(in srgb, var(--bg) 94%, transparent) 12%, color-mix(in srgb, var(--bg) 68%, transparent) 48%, transparent 78%); +} +.dash-header[data-scene-framing='panorama'] .dash-hero__quote::before { + background: radial-gradient(ellipse at 88% 50%, color-mix(in srgb, var(--bg) 94%, transparent) 12%, color-mix(in srgb, var(--bg) 68%, transparent) 48%, transparent 78%); +} +.dash-header[data-scene-framing='panorama'] .dash-hero__quote-text { + color: var(--text-primary); +} +.dash-header[data-scene-framing='panorama'] .dash-hero__quote-by { + color: var(--text-secondary); +} +.dash-header[data-scene-framing='panorama'] .dash-toolbar::before { + content: ''; + position: absolute; + z-index: -1; + inset: -8px 0 0; + pointer-events: none; + background: linear-gradient(transparent, color-mix(in srgb, var(--bg) 78%, transparent) 35%, var(--bg)); +} +.dash-scene-framing { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + margin-top: 16px; +} +.dash-scene-framing button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 5px; + border: 1px solid var(--rule); + border-radius: 8px; + padding: 10px; + background: var(--paper); + color: var(--text-secondary); + font: inherit; + font-size: var(--fs-label); + text-align: left; + cursor: pointer; +} +.dash-scene-framing button[aria-pressed='true'] { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 55%, var(--rule)); + background: var(--accent-soft); +} +.dash-scene-framing small { font-size: 10px; color: var(--text-secondary); line-height: 1.4; } +@container (max-width: 780px) { + .dash-header[data-scene-framing='panorama'] .dash-hero__quote::before { + background: radial-gradient(ellipse, color-mix(in srgb, var(--bg) 88%, transparent) 10%, color-mix(in srgb, var(--bg) 58%, transparent) 46%, transparent 72%); + } +} diff --git a/apps/web/src/features/dashboard/header/sceneSettings.ts b/apps/web/src/features/dashboard/header/sceneSettings.ts index b304ee1..726ba01 100644 --- a/apps/web/src/features/dashboard/header/sceneSettings.ts +++ b/apps/web/src/features/dashboard/header/sceneSettings.ts @@ -1,5 +1,6 @@ export type TrainPalette = 'sunset' | 'morning' | 'night' export type SpringPalette = 'day' | 'dusk' | 'night' +export type SceneFraming = 'panorama' | 'custom' export const TRAIN_DEFAULTS = { speed: .65, billow: 65, detail: 75, steam: 100, @@ -35,19 +36,19 @@ export const SPRING_RANGES = { 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' + palette: SpringPalette | 'auto'; paused: boolean; plane: boolean; sceneAnchor: 'top' | 'bottom'; framing: SceneFraming } export const SPRING_DEFAULTS: SpringSettings = { - ...SPRING_NUMERIC_DEFAULTS, palette: 'auto', paused: false, plane: true, sceneAnchor: 'bottom', + ...SPRING_NUMERIC_DEFAULTS, palette: 'auto', paused: false, plane: true, sceneAnchor: 'bottom', framing: 'panorama', } export type HeaderScene = TrainSettings & { mode: 'cloudtrain' | 'spring' | 'desk'; palette: TrainPalette | 'auto'; paused: boolean - sceneAnchor: 'top' | 'bottom' + sceneAnchor: 'top' | 'bottom'; framing: SceneFraming 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', + ...TRAIN_DEFAULTS, mode: 'cloudtrain', palette: 'auto', paused: false, sceneAnchor: 'bottom', framing: 'panorama', spring: { ...SPRING_DEFAULTS }, } @@ -59,6 +60,7 @@ function normalizeSpring(value: unknown): SpringSettings { 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.framing = saved.framing === 'custom' ? 'custom' : 'panorama' spring.paused = saved.paused === true spring.plane = saved.plane !== false spring.sceneAnchor = saved.sceneAnchor === 'top' ? 'top' : 'bottom' @@ -72,6 +74,7 @@ export function normalizeHeaderScene(value: unknown): HeaderScene { 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.framing = saved.framing === 'custom' ? 'custom' : 'panorama' scene.paused = saved.paused === true scene.sceneAnchor = saved.sceneAnchor === 'top' ? 'top' : 'bottom' for (const key of Object.keys(TRAIN_RANGES) as TrainSetting[]) {