Merge origin/master into xjt/proofreading-active-docs-2-apply

This commit is contained in:
xjt
2026-08-04 19:51:18 +08:00
107 changed files with 2170 additions and 528 deletions

View File

@@ -13,7 +13,7 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import { computeColumns } from './columns.ts'
import { computeColumns, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
@@ -127,7 +127,19 @@ export function AppFrame({
}
}, [])
const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details)
// Narrow viewports auto-collapse the sidebar; the store mirror keeps
// toggleSidebar's semantics right (narrow toggles flip the manual
// re-expand override, stores.ts). Collapsed is decided here, so the
// solver stays breakpoint-free: a narrow re-expand passes the preference
// (or the default when the wide preference is closed) and the center
// absorbs the squeeze.
const narrow = viewport < SIDEBAR_AUTO_COLLAPSE
useEffect(() => { actions.setNarrow(narrow) }, [actions, narrow])
const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0
const sidebarPreference = sidebarCollapsed
? 0
: panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar
const cols = computeColumns(viewport, sidebarPreference, detailsSession === undefined ? 0 : panels.details)
const colsRef = useRef(cols)
colsRef.current = cols
@@ -154,7 +166,7 @@ export function AppFrame({
ref={frameRef}
className={css.frame}
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
data-sidebar-collapsed={panels.sidebar === 0 || undefined}
data-sidebar-collapsed={sidebarCollapsed || undefined}
data-details-collapsed={cols.details === 0 || undefined}
data-dragging={dragging || undefined}
>
@@ -162,9 +174,10 @@ export function AppFrame({
{/* Render-site slot call with live concession output: a closed
sidebar keeps the mounted slot at the compact-rail width, and the
component sees its rendered state as owner params decided here
(collapsed follows the preference, not the resolved width). */}
(collapsed follows the resolved rail, so a derived auto-collapse
renders the rail UI too). */}
{renderSlot('sidebar', {
collapsed: panels.sidebar === 0,
collapsed: sidebarCollapsed,
width: cols.sidebar,
})}
</div>
@@ -178,7 +191,7 @@ export function AppFrame({
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
</div>
)

View File

@@ -8,6 +8,9 @@
* deficit as the last resort. Inputs are the layout store's plain width
* preferences (0 = closed); a closed sidebar resolves to the fixed
* SIDEBAR_COLLAPSED control rail while closed details resolve to zero width.
* The SIDEBAR_AUTO_COLLAPSE breakpoint is consumed by AppFrame, which decides
* the effective sidebar preference before solving; the solver itself stays
* breakpoint-free.
*/
/** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */
@@ -24,6 +27,10 @@ export const SIDEBAR_MAX = 420
export const SIDEBAR_DEFAULT = 280
/** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */
export const SIDEBAR_COLLAPSED = 56
/** Viewport width below which the sidebar auto-collapses to the rail (deepsuite
* LG breakpoint); a manual toggle below it re-expands over the squeezed center
* (stores.ts narrowExpanded). */
export const SIDEBAR_AUTO_COLLAPSE = 1024
/** Details drag clamp floor. */
export const DETAILS_MIN = 300
/** Details drag clamp ceiling. */

View File

@@ -13,8 +13,14 @@ import {
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
/** Layout store state: panel width preferences in px (0 = closed). */
type LayoutState = { sidebar: number; details: number }
/**
* Layout store state: panel width preferences in px (0 = closed), plus the
* narrow-viewport pair — `narrow` mirrors AppFrame's breakpoint reading
* (viewport < SIDEBAR_AUTO_COLLAPSE) so toggleSidebar can pick semantics, and
* `narrowExpanded` is the manual override that re-expands the auto-collapsed
* sidebar over the squeezed center without rewriting the width preference.
*/
type LayoutState = { sidebar: number; details: number; narrow: boolean; narrowExpanded: boolean }
/**
* Annotation twin of the actions literal below (the export needs a declared
@@ -24,6 +30,7 @@ type LayoutActions = {
setSidebar: (draft: LayoutState, px: number) => void
setDetails: (draft: LayoutState, px: number) => void
toggleSidebar: (draft: LayoutState) => void
setNarrow: (draft: LayoutState, narrow: boolean) => void
openDetails: (draft: LayoutState) => void
closeDetails: (draft: LayoutState) => void
}
@@ -33,16 +40,30 @@ type LayoutActions = {
* closing a panel forgets its drag width — reopening restores the contract
* default. Actions are the complete write set: drag writes clamp
* into the panel's contract range and never cross the open/closed line;
* open/close transitions write 0 / the default explicitly.
* open/close transitions write 0 / the default explicitly. Below the
* auto-collapse breakpoint (AppFrame feeds setNarrow) the sidebar toggle
* flips the narrowExpanded override instead of the preference.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
const handle = defineStore({
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false }),
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },
toggleSidebar: (d) => { d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0 },
// Narrow toggles flip only the override: the width preference survives
// untouched, so re-widening restores the pre-squeeze layout.
toggleSidebar: (d) => {
if (d.narrow) d.narrowExpanded = !d.narrowExpanded
else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0
},
// Crossing the breakpoint in either direction drops the override: the
// narrow default is auto-collapsed, the wide state is the preference.
setNarrow: (d, narrow: boolean) => {
if (d.narrow === narrow) return
d.narrow = narrow
d.narrowExpanded = false
},
openDetails: (d) => { if (d.details === 0) d.details = DETAILS_DEFAULT },
closeDetails: (d) => { d.details = 0 },
},

View File

@@ -284,6 +284,50 @@ describe('AppFrame', () => {
})
})
describe('AppFrame — narrow-viewport auto-collapse', () => {
it('mounts collapsed below the breakpoint with no sidebar handle', () => {
frameWidth = 980
const { frame, slotCalls } = mountFrame()
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
expect(slotCalls.filter(c => c.key === 'sidebar').at(-1)!.props).toEqual({ collapsed: true, width: SIDEBAR_COLLAPSED })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(0)
})
it('narrow toggle re-expands over the squeezed center and back', () => {
frameWidth = 980
const { frame, instance } = mountFrame()
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([280, 0])
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(false)
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
})
it('a wide-closed preference re-expands at the contract default while narrow', () => {
frameWidth = 1920
const { frame, instance } = mountFrame()
act(() => { instance.actions.toggleSidebar() }) // close while wide: preference 0
frameWidth = 980
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([280, 0])
expect(instance.getSnapshot().sidebar).toBe(0) // preference untouched
})
it('shrinking across the breakpoint auto-collapses; re-widening restores the drag width', () => {
const { frame, instance } = mountFrame()
act(() => { instance.actions.setSidebar(400) })
frameWidth = 980
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
frameWidth = 1920
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([400, 0])
})
})
describe('AppFrame — guard branches', () => {
it('pointer moves without capture are ignored (no width write)', () => {
const { frame, instance } = mountFrame()

View File

@@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels'
beforeEach(() => { localStorage.clear() })
describe('createLayoutStore', () => {
it('initializes the sidebar at its default width and details closed', () => {
it('initializes the sidebar at its default width, details closed, wide viewport assumed', () => {
const { store } = createLayoutStore().create()
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0, narrow: false, narrowExpanded: false })
})
it('each create() is an independent instance (factory is not a singleton)', () => {
@@ -50,6 +50,30 @@ describe('createLayoutStore', () => {
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('narrow toggleSidebar flips only the re-expand override; the width preference survives', () => {
const { store, actions } = createLayoutStore().create()
actions.setSidebar(400)
actions.setNarrow(true)
actions.toggleSidebar()
expect(store.getSnapshot()).toEqual({ sidebar: 400, details: 0, narrow: true, narrowExpanded: true })
actions.toggleSidebar()
expect(store.getSnapshot().narrowExpanded).toBe(false)
expect(store.getSnapshot().sidebar).toBe(400)
})
it('crossing the breakpoint drops the override; a same-value setNarrow keeps it', () => {
const { store, actions } = createLayoutStore().create()
actions.setNarrow(true)
actions.toggleSidebar()
expect(store.getSnapshot().narrowExpanded).toBe(true)
actions.setNarrow(true)
expect(store.getSnapshot().narrowExpanded).toBe(true)
actions.setNarrow(false)
expect(store.getSnapshot()).toMatchObject({ narrow: false, narrowExpanded: false })
actions.setNarrow(true)
expect(store.getSnapshot().narrowExpanded).toBe(false)
})
it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => {
const { store, actions } = createLayoutStore().create()
actions.openDetails()
@@ -72,6 +96,8 @@ describe('createLayoutStore', () => {
expect(second.store.getSnapshot()).toEqual({
sidebar: SIDEBAR_DEFAULT,
details: 0,
narrow: false,
narrowExpanded: false,
})
})
})

View File

@@ -13,6 +13,7 @@ function fakePanels(): PanelActions {
setSidebar: vi.fn(),
setDetails: vi.fn(),
toggleSidebar: vi.fn(),
setNarrow: vi.fn(),
openDetails: vi.fn(),
closeDetails: vi.fn(),
}