From 20720ef238fd024f87fb235145243cc5b4d5a01f Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 23 Jul 2026 17:08:47 +0800 Subject: [PATCH 01/14] fix(gui): polish sidebar layout, wordmark, tooltip, and fonts to figma Fixed sidebar width (never concedes), figma session-row rail (16px twist + status slots, triangle arrows, mount fade), exact brand wordmark svg with tooltip'd rail controls, form controls inheriting the app font stack, and inlined the twist button reset since the tsdown CSS pipeline drops composes. --- .../ui-layout/src/client/AppFrame.module.css | 16 +- .../client/ui-layout/src/client/AppFrame.tsx | 9 +- .../client/ui-layout/src/client/columns.ts | 51 ++--- .../client/ui-layout/tests/app-frame.spec.tsx | 30 +-- .../client/ui-layout/tests/columns.spec.ts | 41 ++-- .../ui-primitives/src/BrandWordmark.tsx | 56 +++++ .../ui-primitives/src/Tooltip.module.css | 38 ++++ packages/client/ui-primitives/src/Tooltip.tsx | 72 +++++++ .../client/ui-primitives/src/icons/index.tsx | 16 +- packages/client/ui-primitives/src/index.ts | 3 + .../client/ui-primitives/tests/icons.spec.tsx | 4 +- .../ui-sidebar/src/client/Rows.module.css | 72 +++++-- .../client/ui-sidebar/src/client/Rows.tsx | 25 +-- .../src/client/SidebarRoot.module.css | 197 +++++++++--------- .../ui-sidebar/src/client/SidebarRoot.tsx | 136 +++++++----- .../ui-sidebar/tests/sidebar-root.spec.tsx | 19 +- packages/client/web/src/base.css | 10 + 17 files changed, 523 insertions(+), 272 deletions(-) create mode 100644 packages/client/ui-primitives/src/BrandWordmark.tsx create mode 100644 packages/client/ui-primitives/src/Tooltip.module.css create mode 100644 packages/client/ui-primitives/src/Tooltip.tsx diff --git a/packages/client/ui-layout/src/client/AppFrame.module.css b/packages/client/ui-layout/src/client/AppFrame.module.css index 631bb929db..b805bb178a 100644 --- a/packages/client/ui-layout/src/client/AppFrame.module.css +++ b/packages/client/ui-layout/src/client/AppFrame.module.css @@ -86,11 +86,25 @@ height: 32px; border-radius: 10px; box-sizing: border-box; - background: var(--dsw-alias-bg-layer-2); + background: var(--dsw-alias-button-floating-fill); border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + /* Hover affordance: the pill hides until the pointer is over the owning + column (data-side pairs handle and column), the strip itself, or a drag. */ + opacity: 0; + transition: + opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out), + background var(--ds-transition-duration-slow) var(--ds-ease-in-out); +} + +.sidebarCol:hover ~ .handle[data-side='sidebar']::after, +.detailsCol:hover ~ .handle[data-side='details']::after, +.handle:hover::after, +.handle[data-dragging='true']::after { + opacity: 1; } .handle:hover::after, .handle[data-dragging='true']::after { + background: var(--dsw-alias-button-floating-hover); border-color: var(--dsw-alias-border-l3); } diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index e40c94454d..dfa8271075 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -34,8 +34,8 @@ function DetailsColumn(props: { children?: ReactNode }) { return
{props.children}
} -/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. */ -function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) { +/** One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin. `side` keys the hover-reveal CSS to the owning column. */ +function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) { const [dragging, setDragging] = useState(false) const origin = useRef(0) const latest = useRef(0) @@ -72,6 +72,7 @@ function DragHandle(props: { left: number; onStart: () => void; onDrag: (dx: num
{/* The collapsed rail is fixed-width: no resize handle while closed. */} - {panels.sidebar > 0 && } - {cols.details > 0 && } + {panels.sidebar > 0 && } + {cols.details > 0 && }
) } diff --git a/packages/client/ui-layout/src/client/columns.ts b/packages/client/ui-layout/src/client/columns.ts index d7a63aafa2..7cd5f8c2d8 100644 --- a/packages/client/ui-layout/src/client/columns.ts +++ b/packages/client/ui-layout/src/client/columns.ts @@ -1,12 +1,13 @@ /** * Pure concession-chain column solver for the three-column AppFrame. * Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking - * details first, then sidebar, then auto-closing details (derived zero width — - * persisted width preferences are never rewritten, so widening the window - * restores them). Center absorbs any remaining 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. + * details, then auto-closing it (derived zero width — persisted width + * preferences are never rewritten, so widening the window restores them). + * The sidebar never concedes: its rendered width is always the drag + * preference (or the collapsed rail), and center absorbs any remaining + * 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. */ /** Resolved widths for one frame; center may drop below CENTER_MIN only at the final fallback. */ @@ -16,11 +17,11 @@ export interface Columns { sidebar: number; center: number; details: number } /** Center column floor; only the final fallback may go below it. */ export const CENTER_MIN = 640 /** Sidebar drag clamp floor. */ -export const SIDEBAR_MIN = 240 +export const SIDEBAR_MIN = 280 /** Sidebar drag clamp ceiling. */ export const SIDEBAR_MAX = 420 -/** Sidebar width before any user drag. */ -export const SIDEBAR_DEFAULT = 300 +/** Sidebar width before any user drag (= the drag floor). */ +export const SIDEBAR_DEFAULT = 280 /** Closed-sidebar rail: a 24px icon column between 16px horizontal paddings. */ export const SIDEBAR_COLLAPSED = 56 /** Details drag clamp floor. */ @@ -44,38 +45,26 @@ export function clampWidth(px: number, min: number, max: number): number { /** * Solve the three column widths for one viewport frame. Pure: no hysteresis — * the output is a function of (viewport, preferences) only, so recovery on - * re-widening is automatic. After the auto-close step the details pressure is - * gone, so the sidebar returns to its preferred width when it fits. - * Preferences re-clamp here because they cross a durable boundary - * (localStorage rehydration may carry stale ranges). + * re-widening is automatic. Preferences re-clamp here because they cross a + * durable boundary (localStorage rehydration may carry stale ranges). * @param viewport - available frame width in px. * @param sidebar - sidebar width preference in px (0 = closed). * @param details - details width preference in px (0 = closed). * @returns resolved widths; details 0 means visually closed (never unmounted), while a closed sidebar keeps its compact rail. */ export function computeColumns(viewport: number, sidebar: number, details: number): Columns { - const s0 = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) + // The sidebar is fixed at its preference (or the rail) — it never concedes. + const s = sidebar === 0 ? SIDEBAR_COLLAPSED : clampWidth(sidebar, SIDEBAR_MIN, SIDEBAR_MAX) const d0 = details === 0 ? 0 : clampWidth(details, DETAILS_MIN, DETAILS_MAX) // Step 1: everything fits at preferred widths. - if (s0 + d0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0 - d0, details: d0 } + if (s + d0 + CENTER_MIN <= viewport) return { sidebar: s, center: viewport - s - d0, details: d0 } // Step 2: shrink details toward its minimum. - const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s0 - CENTER_MIN) - if (s0 + d1 + CENTER_MIN <= viewport) return { sidebar: s0, center: CENTER_MIN, details: d1 } + const d1 = d0 === 0 ? 0 : Math.max(DETAILS_MIN, viewport - s - CENTER_MIN) + if (s + d1 + CENTER_MIN <= viewport) return { sidebar: s, center: CENTER_MIN, details: d1 } - // Step 3: shrink sidebar toward its minimum (the collapsed rail never shrinks). - const s1 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - d1 - CENTER_MIN) - if (s1 + d1 + CENTER_MIN <= viewport) return { sidebar: s1, center: CENTER_MIN, details: d1 } - - // Step 4: auto-close details (derived — preferences untouched). With the - // details pressure gone the sidebar concession is re-solved from preference. - if (d1 > 0) { - if (s0 + CENTER_MIN <= viewport) return { sidebar: s0, center: viewport - s0, details: 0 } - const s2 = sidebar === 0 ? SIDEBAR_COLLAPSED : Math.max(SIDEBAR_MIN, viewport - CENTER_MIN) - return { sidebar: s2, center: Math.max(0, viewport - s2), details: 0 } - } - - // Step 5: center absorbs the deficit (may drop below CENTER_MIN). - return { sidebar: s1, center: Math.max(0, viewport - s1 - d1), details: d1 } + // Step 3: auto-close details (derived — preferences untouched); center + // absorbs any remaining deficit (may drop below CENTER_MIN). + return { sidebar: s, center: Math.max(0, viewport - s), details: 0 } } diff --git a/packages/client/ui-layout/tests/app-frame.spec.tsx b/packages/client/ui-layout/tests/app-frame.spec.tsx index 120197d531..841e90fc18 100644 --- a/packages/client/ui-layout/tests/app-frame.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.spec.tsx @@ -50,7 +50,7 @@ function hookOf(inst: { subscribe: (fn: () => void) => () => void; getSnapsho function mountFrame() { window.innerWidth = frameWidth // first-render viewport source before the observer fires const instance = createLayoutStore().create() - instance.actions.openDetails() // seed: sidebar at default 300, details open at default 360 + instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360 const slotCalls: { key: string; props: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { slotCalls.push({ key, props: owner }) @@ -116,7 +116,7 @@ afterEach(() => { describe('AppFrame', () => { it('renders three tracks from store state', () => { const { frame } = mountFrame() - expect(tracks(frame)).toEqual([300, 360]) + expect(tracks(frame)).toEqual([280, 360]) }) it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => { @@ -142,13 +142,13 @@ describe('AppFrame', () => { it('sidebar slot receives live concession output as owner props', () => { const { slotCalls } = mountFrame() - expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 300 }) + expect(slotCalls.find((c) => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 }) }) it('sidebar drag widens through rAF-batched pointer moves', () => { const { frame } = mountFrame() const handles = frame.querySelectorAll('[class*="handle"]') - drag(handles[0]!, 300, 350) + drag(handles[0]!, 280, 350) expect(tracks(frame)[0]).toBe(350) }) @@ -160,18 +160,18 @@ describe('AppFrame', () => { }) it('drag base is the rendered (concession-clamped) width, not the preference', () => { - frameWidth = 1250 // step-2 squeeze: details renders 310 while preference is 360 + frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360 const { frame, instance } = mountFrame() - expect(tracks(frame)).toEqual([300, 310]) + expect(tracks(frame)).toEqual([280, 330]) const handles = frame.querySelectorAll('[class*="handle"]') - drag(handles[1]!, 940, 950) // shrink by 10 from the rendered width - expect(instance.getSnapshot().details).toBe(300) + drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width + expect(instance.getSnapshot().details).toBe(320) }) it('details column stays mounted at zero width', () => { const { frame, instance, getByTestId } = mountFrame() act(() => { instance.actions.closeDetails() }) - expect(tracks(frame)).toEqual([300, 0]) + expect(tracks(frame)).toEqual([280, 0]) expect(getByTestId('details-content')).toBeTruthy() expect(frame.hasAttribute('data-details-collapsed')).toBe(true) }) @@ -190,10 +190,10 @@ describe('AppFrame', () => { const { frame } = mountFrame() frameWidth = 1250 act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([300, 310]) + expect(tracks(frame)).toEqual([280, 330]) frameWidth = 1920 act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([300, 360]) + expect(tracks(frame)).toEqual([280, 360]) }) it('drag handles disappear for collapsed columns', () => { @@ -223,7 +223,7 @@ describe('AppFrame — guard branches', () => { it('two moves inside one frame coalesce through the pending rAF', () => { const { frame, instance } = mountFrame() const handle = frame.querySelectorAll('[class*="handle"]')[0]! - act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) }) + act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) }) act(() => { // Two moves before the frame flushes: the second must ride the pending // rAF (frame.current ??= guard), and the flush sees the latest x. @@ -238,7 +238,7 @@ describe('AppFrame — guard branches', () => { it('pointerup with a pending rAF cancels it and commits the final position', () => { const { frame, instance } = mountFrame() const handle = frame.querySelectorAll('[class*="handle"]')[0]! - act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 300, bubbles: true })) }) + act(() => { handle.dispatchEvent(new PointerEvent('pointerdown', { pointerId: 1, clientX: 280, bubbles: true })) }) act(() => { handle.dispatchEvent(new PointerEvent('pointermove', { pointerId: 1, clientX: 360, bubbles: true })) // No timer advance: the rAF is still pending when pointerup arrives. @@ -252,7 +252,7 @@ describe('AppFrame — guard branches', () => { frameWidth = 0 act(() => { fireResize?.(); vi.advanceTimersByTime(20) }) // Track template still reflects the last non-zero viewport. - expect(tracks(frame)).toEqual([300, 360]) + expect(tracks(frame)).toEqual([280, 360]) }) }) @@ -270,6 +270,6 @@ describe('AppFrame — unmount with an in-flight resize frame', () => { const { frame } = mountFrame() frameWidth = 1250 act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) }) - expect(tracks(frame)).toEqual([300, 310]) + expect(tracks(frame)).toEqual([280, 330]) }) }) diff --git a/packages/client/ui-layout/tests/columns.spec.ts b/packages/client/ui-layout/tests/columns.spec.ts index 6358c45076..ae8c39a117 100644 --- a/packages/client/ui-layout/tests/columns.spec.ts +++ b/packages/client/ui-layout/tests/columns.spec.ts @@ -19,7 +19,7 @@ describe('clampWidth', () => { describe('computeColumns', () => { it('step 1: everything fits at preferred widths', () => { const cols = computeColumns(1920, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 300, center: 1920 - 300 - 360, details: 360 }) + expect(cols).toEqual({ sidebar: 280, center: 1920 - 280 - 360, details: 360 }) }) it('closed sidebar keeps its compact rail while closed details contribute zero width', () => { @@ -31,12 +31,13 @@ describe('computeColumns', () => { const cols = computeColumns(1920, open(9999), open(1)) expect(cols.sidebar).toBe(420) expect(cols.details).toBe(300) + expect(computeColumns(1920, open(1), open(DETAILS_DEFAULT)).sidebar).toBe(SIDEBAR_MIN) }) it('step 2: details shrinks first, center pinned at min', () => { - // 300 + 360 + 640 = 1300 > 1250; details concedes to 1250-300-640 = 310. + // 280 + 360 + 640 = 1280 > 1250; details concedes to 1250-280-640 = 330. const cols = computeColumns(1250, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 300, center: CENTER_MIN, details: 310 }) + expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: 330 }) }) it('boundary: exactly at the step-1/step-2 seam', () => { @@ -46,28 +47,16 @@ describe('computeColumns', () => { expect(one).toEqual({ sidebar: 300, center: CENTER_MIN, details: 359 }) }) - it('step 3: sidebar concedes after details hits its min', () => { - // details floor 300: sidebar = 1220-300-640 = 280. - const cols = computeColumns(1220, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 280, center: CENTER_MIN, details: DETAILS_MIN }) + it('step 3: details auto-closes when its min still starves center — sidebar holds its preference', () => { + // 280 + 300 + 640 = 1220 > 1210 → details 0; sidebar untouched: center = 1210-280 = 930. + const cols = computeColumns(1210, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) + expect(cols).toEqual({ sidebar: 280, center: 930, details: 0 }) }) - it('step 4: details auto-closes when both panels are at min and center still starves', () => { - // 240 + 300 + 640 = 1180 > 1100 → details 0; sidebar preference (300) fits: 1100-300 = 800 center. - const cols = computeColumns(1100, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 300, center: 800, details: 0 }) - }) - - it('step 4 keeps squeezing sidebar when preference no longer fits', () => { - // 900 < 300+640: sidebar = max(240, 900-640) = 260. - const cols = computeColumns(900, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: 260, center: CENTER_MIN, details: 0 }) - }) - - it('step 5: center absorbs the deficit as last resort (details closed)', () => { - // 700 < 240+640: sidebar floors at 240, center takes 460 < CENTER_MIN. + it('the sidebar never concedes: center absorbs the deficit below CENTER_MIN', () => { + // 700 < 280+640: sidebar keeps 280, center takes 420 < CENTER_MIN. const cols = computeColumns(700, open(SIDEBAR_DEFAULT), closed(DETAILS_DEFAULT)) - expect(cols).toEqual({ sidebar: SIDEBAR_MIN, center: 460, details: 0 }) + expect(cols).toEqual({ sidebar: SIDEBAR_DEFAULT, center: 420, details: 0 }) }) it('sidebar-closed narrow window: details concedes then auto-closes', () => { @@ -81,11 +70,11 @@ describe('computeColumns', () => { }) }) - it('tiny viewport: both panels yield everything to center', () => { + it('tiny viewport: details closes, sidebar holds, center takes the remainder', () => { const cols = computeColumns(400, open(SIDEBAR_DEFAULT), open(DETAILS_DEFAULT)) expect(cols.details).toBe(0) - expect(cols.sidebar).toBe(SIDEBAR_MIN) - expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_MIN)) + expect(cols.sidebar).toBe(SIDEBAR_DEFAULT) + expect(cols.center).toBe(Math.max(0, 400 - SIDEBAR_DEFAULT)) }) it('recovery is pure: re-widening restores preferred widths untouched', () => { @@ -99,7 +88,7 @@ describe('computeColumns', () => { describe('computeColumns — degenerate viewports', () => { it('sidebar closed and viewport below CENTER_MIN: details auto-closes, center takes the rest', () => { - // Reaches step 4's re-solve with the compact rail as the sidebar floor. + // Reaches step 3's auto-close with the compact rail sidebar. expect(computeColumns(500, closed(300), open(DETAILS_DEFAULT))) .toEqual({ sidebar: SIDEBAR_COLLAPSED, center: 500 - SIDEBAR_COLLAPSED, details: 0 }) }) diff --git a/packages/client/ui-primitives/src/BrandWordmark.tsx b/packages/client/ui-primitives/src/BrandWordmark.tsx new file mode 100644 index 0000000000..aa45d046f0 --- /dev/null +++ b/packages/client/ui-primitives/src/BrandWordmark.tsx @@ -0,0 +1,56 @@ +// DeepSeek Harness brand wordmark (figma 356:14644, exact extract): whale + +// "deepseek" letterforms + HARNESS badge plate in one svg. Native 182x24. +// Ink rides currentColor; the badge text is knocked out in the inverted +// label color so the plate stays legible in both themes. + +import type { IconProps } from './icons/props.ts' + +/** + * Render the full brand wordmark. + * @param props.size - height in px (default 24; width keeps the 182:24 ratio). + * @param props.className - extra class for layout placement. + * @returns the wordmark svg (aria-hidden decorative brand art). + */ +export function BrandWordmark({ size = 24, className }: IconProps) { + return ( + + ) +} diff --git a/packages/client/ui-primitives/src/Tooltip.module.css b/packages/client/ui-primitives/src/Tooltip.module.css new file mode 100644 index 0000000000..5853531bd4 --- /dev/null +++ b/packages/client/ui-primitives/src/Tooltip.module.css @@ -0,0 +1,38 @@ +/* Visual spec mirrors deepsuite @deepseek/ui Tooltip.css (size m, no arrow), + except padding tightened 6/12 -> 4/8 and radius 10 -> 8 by product ruling: + tooltip-bg plate, + one text color across both themes (the plate stays dark in light and dark + mode). Behavior (fixed positioning off the anchor rect) is local — the + upstream Floating stack is intentionally not vendored. */ + +.bubble { + position: fixed; + z-index: 100; + padding: 4px 8px; + border-radius: 8px; + background: var(--dsw-alias-tooltip-bg); + color: var(--dsw-static-neutral-bluish-00); + font-size: 14px; + line-height: 22px; + white-space: nowrap; + pointer-events: none; + animation: tooltip-in 150ms var(--ds-ease-in-out); +} + +.bubble[data-side='right'] { + transform: translateY(-50%); +} + +.bubble[data-side='bottom'] { + transform: translateX(-50%); +} + +@keyframes tooltip-in { + from { opacity: 0; } +} + +@media (prefers-reduced-motion: reduce) { + .bubble { + animation: none; + } +} diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx new file mode 100644 index 0000000000..21191aefb3 --- /dev/null +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -0,0 +1,72 @@ +// Hover/focus label bubble (figma tooltip pill: dark plate, white text). +// TODO: interaction is a placeholder (no show delay, no flip on viewport +// collision, no arrow) — visuals and behavior get a proper pass later. +// The anchor is the child element itself (cloneElement, no wrapper node), so +// attaching a tooltip never changes the anchor's layout context. The bubble is +// position:fixed and coordinates come from the anchor's rect at show time, so +// it escapes ancestor overflow clipping (the sidebar rail clips its column) +// without a portal. + +import { cloneElement, useEffect, useRef, useState } from 'react' +import type { FocusEventHandler, MouseEventHandler, ReactElement, Ref } from 'react' +import css from './Tooltip.module.css' + +/** Bubble placement relative to the anchor. */ +export type TooltipSide = 'right' | 'bottom' + +/** Props Tooltip injects into its anchor child; the child's own handlers are chained ahead of the tooltip's. */ +interface AnchorProps { + ref?: Ref | undefined + onMouseEnter?: MouseEventHandler | undefined + onMouseLeave?: MouseEventHandler | undefined + onFocus?: FocusEventHandler | undefined + onBlur?: FocusEventHandler | undefined +} + +/** + * Attach a hover/focus tooltip to an anchor element. + * @param props.label - bubble text. + * @param props.side - placement relative to the anchor (default 'right'). + * @param props.disabled - suppress the bubble while true; the anchor renders identically so toggling never remounts it (which would cut its CSS transitions). + * @param props.children - a single anchor element. Tooltip owns its ref (no current consumer passes one). + * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. + */ +export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement }) { + const anchor = useRef(null) + const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + + // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) + // must drop an already-visible bubble: no mouseleave fires. + useEffect(() => { + if (disabled) setPos(null) + }, [disabled]) + + const show = () => { + if (disabled) return + const el = anchor.current + /* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */ + if (el === null) return + const r = el.getBoundingClientRect() + setPos(side === 'right' + ? { x: r.right + 10, y: r.top + r.height / 2 } + : { x: r.left + r.width / 2, y: r.bottom + 8 }) + } + const hide = () => { setPos(null) } + + return ( + <> + {cloneElement(children, { + ref: anchor, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); show() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); hide() }, + onFocus: (e) => { children.props.onFocus?.(e); show() }, + onBlur: (e) => { children.props.onBlur?.(e); hide() }, + })} + {pos !== null && ( + + {label} + + )} + + ) +} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 4f35833fb6..80bb3848dd 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -165,6 +165,16 @@ export const IconChevronRightOutline14 = ({ size = 14, className }: IconProps) = ) +/** ic_ds_triangle_right_fill_14 — tree expand arrow; points right, consumers rotate it 90° for the open state. */ +export const IconTriangleRightFill14 = ({ size = 14, className }: IconProps) => ( + + + +) + /** ic_ds_chevron_up_outline_14 */ export const IconChevronUpOutline14 = ({ size = 14, className }: IconProps) => ( @@ -552,11 +562,11 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) => ) -/** folder_open_16 (figma extract) */ +/** folder_open_16 (figma extract): outline at full ink + 20%-opacity inner fill riding the same currentColor. */ export const IconFolderOpen16 = ({ size = 16, className }: IconProps) => ( - - + + ) diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index daea4202b3..3dc3128056 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -14,6 +14,9 @@ export { Menu } from './Menu.tsx' export type { MenuItem } from './Menu.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' +export { BrandWordmark } from './BrandWordmark.tsx' +export { Tooltip } from './Tooltip.tsx' +export type { TooltipSide } from './Tooltip.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MessageText } from './markdown/MessageText.tsx' export * from './icons/index.tsx' diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index ee396af4f5..74bf5a9678 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -14,8 +14,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full P-I set (43 deepsuite + 6 figma extracts)', () => { - expect(iconNames.length).toBe(49) + it('exports the full P-I set (43 deepsuite + 7 figma extracts)', () => { + expect(iconNames.length).toBe(50) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', name => { diff --git a/packages/client/ui-sidebar/src/client/Rows.module.css b/packages/client/ui-sidebar/src/client/Rows.module.css index 65b6f0fa5e..18539a5f61 100644 --- a/packages/client/ui-sidebar/src/client/Rows.module.css +++ b/packages/client/ui-sidebar/src/client/Rows.module.css @@ -24,12 +24,38 @@ background: var(--dsw-alias-interactive-bg-active); } +/* Two-line row: the leading slot (folder/chevron), title, and trailing + actions all top-align on the 20px first text line (figma cell) — content + is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */ .projectRow { height: 54px; + align-items: flex-start; + padding-top: 6px; + padding-bottom: 6px; + box-sizing: border-box; } +.projectRow .rowActions { + height: 20px; +} + +/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px + gap to the title — the slots butt together, so the row gap is zeroed and + the title carries its own margins. */ .sessionRow { height: 34px; + gap: 0; + /* Mount fade: session rows appear by unfolding a group (or the tree + mounting). Stable row keys keep already-visible rows from replaying it. */ + animation: row-in 150ms var(--ds-ease-in-out); +} + +.sessionRow .title { + margin: 0 6px 0 4px; +} + +@keyframes row-in { + from { opacity: 0; } } .slot { @@ -47,11 +73,20 @@ color: var(--dsw-alias-state-business-primary); } -/* Project leading slot: folder by default, chevron on row hover. */ +/* Project leading slot: folder by default, expand arrow on row hover. */ .projectRow .chevron { display: none; } .projectRow:hover .chevron { display: inline-flex; } .projectRow:hover .folder { display: none; } +/* Expand arrow (filled triangle): points right closed, rotates to point down open. */ +.arrow { + transition: transform 150ms var(--ds-ease-in-out); +} + +.arrowOpen { + transform: rotate(90deg); +} + .projectText { flex: 1; min-width: 0; @@ -131,22 +166,25 @@ } /* Session expand twist occupies the leading 16px slot; keep a spacer when absent - so titles align across sibling rows. */ + so titles align across sibling rows. Duplicates the .iconButton reset instead + of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which + left the raw UA button box showing. */ .twist { - composes: iconButton; - width: 16px; - height: 20px; -} - -/* "L" connector slot (figma arrow 14:3071): 16x16, glyph right-aligned. */ -.cornerSlot { flex: none; - width: 16px; - height: 16px; display: inline-flex; align-items: center; - justify-content: flex-end; - color: var(--dsw-alias-label-caption); + justify-content: center; + width: 16px; + height: 20px; + border: none; + border-radius: 4px; + padding: 0; + background: transparent; + cursor: pointer; +} + +.twist:hover { + color: var(--dsw-alias-label-primary); } /* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph @@ -156,3 +194,11 @@ .twist { color: var(--dsw-alias-label-caption); } + +@media (prefers-reduced-motion: reduce) { + .sessionRow, + .arrow { + animation: none; + transition: none; + } +} diff --git a/packages/client/ui-sidebar/src/client/Rows.tsx b/packages/client/ui-sidebar/src/client/Rows.tsx index c24f6b9fb5..53f8bbe14f 100644 --- a/packages/client/ui-sidebar/src/client/Rows.tsx +++ b/packages/client/ui-sidebar/src/client/Rows.tsx @@ -5,16 +5,15 @@ */ import clsx from 'clsx' import { - IconChevronDownOutline14, IconChevronRightOutline14, IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, - IconTreeCorner8x10, StateDot, + IconTriangleRightFill14, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ProjectRow, SessionRow } from './tree.ts' import { formatRelativeTime } from './tree.ts' import css from './Rows.module.css' -/** Indent step per tree level: 16px slot + 6px gap (figma). */ -const INDENT_STEP = 22 +/** Indent step per tree level: one 16px slot (figma session cell). */ +const INDENT_STEP = 16 /** * Project (workspace) row: 54px, folder + title + session count; hover @@ -38,7 +37,7 @@ export function ProjectRowItem({ row, active, onToggle, onCreate }: { {row.expanded ? : } - {row.expanded ? : } + {row.label} @@ -79,17 +78,16 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: { onOpen: () => void onToggle: () => void }) { - // Rail (figma sub-cell slot sequence): twist slot, always-reserved state - // slot (opacity-0 slots keep their 22px in figma, so titles align whether - // or not the dot is lit), then the L connector on child rows. Extra depth - // rides the left padding: indent spacers = depth - 1. + // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to + // the title): both slots are always reserved so titles align whether or not + // the twist/dot is lit. Extra depth rides the left padding. return (
{row.hasChildren @@ -100,16 +98,11 @@ export function SessionRowItem({ row, selected, now, onOpen, onToggle }: { aria-label={row.expanded ? 'Collapse' : 'Expand'} onClick={(e) => { e.stopPropagation(); onToggle() }} > - {row.expanded ? : } + ) : } {row.running && } - {row.depth > 0 && ( - - - - )} {row.title} {formatRelativeTime(row.updatedAt, now)} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index c580d47b75..621b33fc66 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -1,41 +1,62 @@ -/* Sidebar column (figma 133:7629): vertical stack, padding 16/6, sidebar - fill + 1px right border painted by the layout column. Collapse morphs in - place: the four control rows persist into the 56px rail (one icon each, - x-converged by the shrinking column), geometry rides the deepsuite curve - while wide-only content cross-fades 200ms; explicit margins own the - vertical rhythm in both states so every gap can transition. */ +/* Sidebar column (figma 133:7629): vertical stack, padding 12/6, sidebar + fill + 1px right border painted by the layout column. Collapse is a + slide + crossfade, not a morph: the content holds its frozen expanded + layout (inline width set by the component) and fades in place (.fading) + while the sliding column (AppFrame grid tracks) clips it; the rail layout + (.collapsed) only applies after the fade settles, so nothing reflows + mid-slide. */ .root { display: flex; flex-direction: column; height: 100%; - padding: 6px 16px; + padding: 6px 12px; box-sizing: border-box; background: var(--dsw-specific-sidebar-fill); color: var(--dsw-alias-label-primary); font-size: 14px; - transition: padding var(--ds-transition-duration-slow) var(--ds-ease-in-out); } +/* Rail geometry (figma rail spec): 36x36 control boxes centered in the 56px + rail (10px side padding), 12px vertical rhythm, 18px from the rail top to + the whale's box (24px to the 24-wide whale glyph itself). */ .root.collapsed { - padding-top: 14px; + padding: 18px 10px 6px; } -/* Wide-only content: fades ahead of the geometry (200ms vs 300ms) and - unmounts once the collapse settles; remounts fade back in. */ +/* Collapse phase 1: the whole frozen-width content fades out in place over + 150ms; at settle the children unmount/snap to the rail layout. */ +.fading > * { + opacity: 0; + transition: opacity 150ms var(--ds-ease-in-out); +} + +/* Wide-only content fades back in on expand remount. */ .wide { animation: wide-in 200ms var(--ds-ease-in-out); - transition: opacity 200ms var(--ds-ease-in-out); -} - -.collapsed .wide { - opacity: 0; } @keyframes wide-in { from { opacity: 0; } } +/* Rail controls hold hidden while the column slides shut, then fade in over + the slide's tail: .railIn applies at settle (150ms into the 0.3s AppFrame + track transition), so a 100ms delay + 150ms fade starts just before the + slide ends (250ms) and finishes at 400ms; `backwards` keeps them at + opacity 0 through the delay. Only a live collapse gets .railIn — a + refresh straight into the collapsed state renders statically. */ +.railIn .iconButton, +.railIn .newSession, +.railIn .searchButton, +.railIn .foot { + animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; +} + +@keyframes rail-in { + from { opacity: 0; } +} + /* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored — the toggle is the rail's expand control and slides in with the right edge. */ .logoRow { @@ -45,23 +66,19 @@ justify-content: flex-end; gap: 8px; height: 60px; - padding: 8px 4px; + padding: 8px 0 8px 4px; margin-bottom: 16px; box-sizing: border-box; overflow: hidden; - transition: - height var(--ds-transition-duration-slow) var(--ds-ease-in-out), - padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), - margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .logoRow { - height: 24px; + height: 36px; padding: 0; - margin-bottom: 8px; + margin-bottom: 12px; } -/* Brand group (figma I133:7632): fish + wordmark ride the text ink +/* Brand group (figma I133:7632): the full wordmark rides the text ink (figma-flows ruling: main-screen instance is black; blue is brand emphasis only). */ .brand { @@ -69,28 +86,9 @@ min-width: 0; display: inline-flex; align-items: center; - gap: 7px; overflow: hidden; } -.wordmark { - font-weight: 600; - white-space: nowrap; -} - -/* HARNESS badge (figma 34:10358): 14px tall, mono 11/500 on primary fill. */ -.badge { - flex: none; - padding: 0 3px; - border-radius: 2px; - background: var(--dsw-alias-label-primary); - color: var(--dsw-alias-label-primary-inverted); - font-family: var(--ds-font-family-code); - font-size: 11px; - font-weight: 500; - line-height: 14px; -} - .iconButton { flex: none; display: inline-flex; @@ -104,9 +102,6 @@ background: transparent; cursor: pointer; color: var(--dsw-alias-label-secondary); - transition: - width var(--ds-transition-duration-slow) var(--ds-ease-in-out), - height var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .iconButton:hover { @@ -114,12 +109,33 @@ } .collapsed .iconButton { - width: 24px; - height: 24px; + width: 36px; + height: 36px; } -/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain - icon control — border and fill fade with the label. */ +/* Rail logo swap: collapsed, the toggle rests as the whale mark (brand ink, + no hover circle) and hovering reveals the panel icon — the expand + affordance (figma sidebar-hover flow). Expanded it is a plain panel icon. */ +.collapsed .toggle .panelIcon { + display: none; +} + +.collapsed .toggle:hover .panelIcon { + display: inline; +} + +.collapsed .toggle:hover .railFish { + display: none; +} + +/* Rail icons ride the primary ink (figma rail spec); expanded keeps the + secondary icon-button ink. */ +.collapsed .iconButton { + color: var(--dsw-alias-label-primary); +} + +/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the + rail's plain icon control. */ .newSession { flex: none; display: flex; @@ -128,24 +144,17 @@ gap: 6px; height: 38px; padding: 8px 16px; - margin-bottom: 20px; /* former headerBlock padBottom 12 + root gap 8 */ + margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); border-radius: 24px; background: var(--dsw-alias-button-elevated-fill); color: var(--dsw-alias-label-primary); font-size: 14px; - font-weight: 510; + font-weight: 500; line-height: 22px; cursor: pointer; overflow: hidden; - transition: - height var(--ds-transition-duration-slow) var(--ds-ease-in-out), - padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), - margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), - gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), - border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), - background-color 200ms var(--ds-ease-in-out); } .newSession:hover { @@ -153,9 +162,9 @@ } .collapsed .newSession { - height: 24px; + height: 36px; padding: 0; - margin-bottom: 8px; + margin: 0 0 12px; gap: 0; border-color: transparent; background: transparent; @@ -169,7 +178,6 @@ max-width: 200px; overflow: hidden; white-space: nowrap; - transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .newSessionLabel { @@ -191,16 +199,12 @@ border-radius: 12px; overflow: hidden; color: var(--dsw-alias-label-tertiary); - transition: - height var(--ds-transition-duration-slow) var(--ds-ease-in-out), - padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), - margin var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .sectionHeader { - height: 24px; + height: 36px; padding-left: 0; - margin-bottom: 8px; + margin-bottom: 12px; } .sectionLabel { @@ -211,8 +215,8 @@ line-height: 20px; } -/* Search input: 38px capsule (figma 133:7649) morphing into the rail's - search control. Upstream binds a dedicated design-system variable (light +/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the + rail's search control. Upstream binds a dedicated design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token pinned to the static scale mirrors it (ruled compliant: indirect via custom property, upstream-variable equivalent). */ @@ -223,7 +227,7 @@ align-items: center; gap: 8px; height: 38px; - margin-bottom: 12px; /* former listArea gap 4 + own 8 (spec padB12 to the first cell) */ + margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */ padding: 0 14px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); @@ -231,13 +235,6 @@ background: var(--dsh-search-input-fill); color: var(--dsw-alias-label-caption); overflow: hidden; - transition: - height var(--ds-transition-duration-slow) var(--ds-ease-in-out), - padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), - margin var(--ds-transition-duration-slow) var(--ds-ease-in-out), - gap var(--ds-transition-duration-slow) var(--ds-ease-in-out), - border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out), - background-color 200ms var(--ds-ease-in-out); } :global(body[data-ds-dark-theme]) .search { @@ -245,9 +242,9 @@ } .collapsed .search { - height: 24px; + height: 36px; padding: 0; - margin-bottom: 8px; + margin: 0 0 12px; gap: 0; border-color: transparent; background: transparent; @@ -261,8 +258,6 @@ display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; border: none; border-radius: 50%; padding: 0; @@ -272,9 +267,11 @@ } .collapsed .searchButton { + width: 36px; + height: 36px; pointer-events: auto; cursor: pointer; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-primary); } .collapsed .searchButton:hover { @@ -366,39 +363,41 @@ font-size: 13px; } -/* Foot: settings entry (figma 133:7668). Left padding lands the 14px glyph - on the rail's icon axis when collapsed. */ +/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical + margins fold into the row so the hover pill spans the full 49px. */ .foot { flex: none; display: flex; align-items: center; gap: 8px; - height: 29px; - margin: 18px 0 10px; /* former root gap 8 + own 10 above; root padBottom 6 below */ + height: 49px; + margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */ padding: 0 2px 0 6px; border-radius: 12px; cursor: pointer; overflow: hidden; color: var(--dsw-alias-label-primary); - transition: - padding var(--ds-transition-duration-slow) var(--ds-ease-in-out), - gap var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .foot:hover { background: var(--dsw-alias-interactive-bg-hover); } +/* Rail settings: the same 36x36 circle box as the other rail controls. */ .collapsed .foot { + width: 36px; + height: 36px; + margin: 18px 0 10px; + justify-content: center; gap: 0; - padding: 0 0 0 5px; + padding: 0; + border-radius: 50%; } .footLabel { max-width: 120px; overflow: hidden; white-space: nowrap; - transition: max-width var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .collapsed .footLabel { @@ -406,16 +405,12 @@ } @media (prefers-reduced-motion: reduce) { - .root, .wide, - .logoRow, - .iconButton, - .newSession, - .newSessionLabel, - .sectionHeader, - .search, - .foot, - .footLabel { + .fading > *, + .railIn .iconButton, + .railIn .newSession, + .railIn .searchButton, + .railIn .foot { transition: none; animation: none; } diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index a2f730b2d4..ed707769ce 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -6,28 +6,32 @@ * state, and rows are derived in render via useMemo (slot design section 6: * derived data is a pure function, no materializing store). * - * Collapse is a morph, not a swap: the four control rows persist into the - * 56px rail (collapse/new session/new workspace/search, one icon each, same - * top-down order as their expanded rows) and animate their geometry on the - * deepsuite curve, while wide-only content (brand, labels, input, tree) - * cross-fades out and unmounts once the collapse settles — dropping the - * sessions subscription. Rail search expands and focuses the search box. + * Collapse is a slide + crossfade: the content freezes at its expanded + * width (inline style) and fades out in place while the sliding column + * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle + * the wide-only content (brand, labels, input, tree) unmounts, dropping + * the sessions subscription, and the control rows snap to the 56px rail + * (one icon each, same top-down order) fading in as the slide ends. Rail + * search expands and focuses the search box. */ import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import clsx from 'clsx' import { - FishLogo, + BrandWordmark, FishLogo, IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, - Menu, + Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarRootComponentProps } from './contract/slots.ts' import { deriveRows } from './tree.ts' import { ProjectRowItem, SessionRowItem } from './Rows.tsx' import css from './SidebarRoot.module.css' -/** Wide-content unmount delay; matches --ds-transition-duration-slow (0.3s). */ -const COLLAPSE_SETTLE_MS = 300 +/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ +const COLLAPSE_SETTLE_MS = 150 + +/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ +const EXPAND_SLIDE_MS = 300 const GROUP_BY_ITEMS = [ { id: 'workspace', label: 'WorkSpace' }, @@ -134,7 +138,7 @@ function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). * @returns the sidebar element tree. */ -export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { +export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) { // The query outlives the tree and the input (both wide-only) so collapsing // does not silently drop an in-progress filter. const [query, setQuery] = useState('') @@ -150,72 +154,98 @@ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggle }, [collapsed]) const wide = !collapsed || !settled + // Freeze the content at its expanded width while it fades out (collapsed + // && wide): the sliding column then clips it instead of reflowing it. The + // rail layout (.collapsed styles) only applies once the fade settles. + const lastWideWidth = useRef(width) + if (!collapsed) lastWideWidth.current = width + + // Rail-in only crossfades a live collapse: a refresh straight into the + // collapsed state renders the rail statically (no delay-hidden icons). + const everWide = useRef(!collapsed) + if (!collapsed) everWide.current = true + // Rail search = expand + land in the search box: the flag arms before the // expand toggle; once expanded the input is mounted and takes focus. const [searchOnExpand, setSearchOnExpand] = useState(false) useEffect(() => { if (!collapsed && searchOnExpand) { - searchInput.current?.focus() - setSearchOnExpand(false) + const timer = window.setTimeout(() => { + searchInput.current?.focus({ preventScroll: true }) + setSearchOnExpand(false) + }, EXPAND_SLIDE_MS) + return () => { window.clearTimeout(timer) } } }, [collapsed, searchOnExpand]) return ( -
+
{wide && ( - {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */} - - deepseek - HARNESS + )} - + {/* Rail resting state is the whale mark; hovering swaps in the panel + icon (the expand affordance, figma sidebar-hover flow). */} + + +
- + + +
{wide && WorkSpace} {wide && } - + + +
{/* Expanded: the row is a click-to-focus field (the leading icon is decorative). Collapsed: the icon is the rail's search control. */}
{ if (!collapsed) searchInput.current?.focus() }}> - + + + {wide && (
- + {wide && Settings}
diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index b0e8a9f769..d4d85bf3c0 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -90,10 +90,13 @@ const projectData = () => [ /** Flush the store's microtask-batched notification into React. */ const flush = async () => { await act(async () => { await Promise.resolve() }) } +/** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */ +const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]') + describe('SidebarRoot', () => { it('renders chrome and collapsed project rows', () => { mount(...projectData()) - expect(screen.getByText('HARNESS')).toBeTruthy() + expect(wordmark()).not.toBeNull() expect(screen.getByText('New Session')).toBeTruthy() expect(screen.getByText('proj')).toBeTruthy() expect(screen.getByText('2 sessions')).toBeTruthy() @@ -165,15 +168,15 @@ describe('SidebarRoot', () => { act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledOnce() // Fade window: the wide chrome is still mounted while it fades. - expect(screen.getByText('HARNESS')).toBeTruthy() + expect(wordmark()).not.toBeNull() expect(screen.getByRole('tree')).toBeTruthy() // Settle: wide content unmounts, the rail controls remain. act(() => { vi.advanceTimersByTime(300) }) - expect(screen.queryByText('HARNESS')).toBeNull() + expect(wordmark()).toBeNull() expect(screen.queryByText('New Session')).toBeNull() expect(screen.queryByRole('tree')).toBeNull() - // Rail order mirrors the expanded rows: expand, new session, new workspace, search. - const rail = ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings'] + // Rail order mirrors the expanded rows: open, new session, new workspace, search. + const rail = ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings'] .map((label) => screen.getByLabelText(label)) for (let i = 1; i < rail.length; i++) { expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() @@ -181,7 +184,7 @@ describe('SidebarRoot', () => { // Rail creation entries route like their expanded counterparts. act(() => { fireEvent.click(screen.getByLabelText('New session')) }) expect(onCreate).toHaveBeenLastCalledWith() - act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) }) expect(onToggleSidebar).toHaveBeenCalledTimes(2) expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy() expect(screen.getByText('New Session')).toBeTruthy() @@ -198,6 +201,8 @@ describe('SidebarRoot', () => { act(() => { vi.advanceTimersByTime(300) }) act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) expect(onToggleSidebar).toHaveBeenCalledTimes(2) + // Focus waits out the 300ms column slide (EXPAND_SLIDE_MS). + act(() => { vi.advanceTimersByTime(300) }) const input = screen.getByPlaceholderText('Search name, keywords...') expect(document.activeElement).toBe(input) } finally { @@ -213,7 +218,7 @@ describe('SidebarRoot', () => { act(() => { fireEvent.change(input, { target: { value: 'forked' } }) }) act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) act(() => { vi.advanceTimersByTime(300) }) - act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) }) + act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) }) const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement expect(restored.value).toBe('forked') expect(screen.getByText('forked child')).toBeTruthy() diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css index 53dbde8db7..991a03bbca 100644 --- a/packages/client/web/src/base.css +++ b/packages/client/web/src/base.css @@ -17,3 +17,13 @@ body { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-base); } + +/* Form controls don't inherit the body font (UA sheets pin their families — + Chrome buttons fall back to Arial, textareas to monospace), so the app + stack is re-applied to them explicitly, as upstream's global reset does. */ +button, +input, +select, +textarea { + font-family: inherit; +} From 789e9daaf6cd37d7eb8b28bcb73dfe6eaf191f4c Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Thu, 23 Jul 2026 17:08:47 +0800 Subject: [PATCH 02/14] test(gui): cover the tooltip primitive and the inert expanded search control --- .../ui-primitives/tests/tooltip.spec.tsx | 100 ++++++++++++++++++ .../ui-sidebar/tests/sidebar-root.spec.tsx | 3 + 2 files changed, 103 insertions(+) create mode 100644 packages/client/ui-primitives/tests/tooltip.spec.tsx diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx new file mode 100644 index 0000000000..c71124040d --- /dev/null +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -0,0 +1,100 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) + +describe('Tooltip', () => { + it('shows the bubble to the right on hover and hides it on leave', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + fireEvent.mouseEnter(anchor) + const bubble = screen.getByRole('tooltip') + expect(bubble.textContent).toBe('Open sidebar') + expect(bubble.getAttribute('data-side')).toBe('right') + // jsdom rects are all-zero: right placement lands at the +10 gutter. + expect(bubble.style.left).toBe('10px') + expect(bubble.style.top).toBe('0px') + fireEvent.mouseLeave(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it('supports bottom placement and the focus/blur channel', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + fireEvent.focus(anchor) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-side')).toBe('bottom') + expect(bubble.style.left).toBe('0px') + expect(bubble.style.top).toBe('8px') + fireEvent.blur(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => { + const onMouseEnter = vi.fn() + const onMouseLeave = vi.fn() + const onFocus = vi.fn() + const onBlur = vi.fn() + render( + + + , + ) + const anchor = screen.getByText('anchor') + fireEvent.mouseEnter(anchor) + fireEvent.mouseLeave(anchor) + fireEvent.focus(anchor) + fireEvent.blur(anchor) + expect(onMouseEnter).toHaveBeenCalledOnce() + expect(onMouseLeave).toHaveBeenCalledOnce() + expect(onFocus).toHaveBeenCalledOnce() + expect(onBlur).toHaveBeenCalledOnce() + }) + + it('suppresses the bubble while disabled without remounting the anchor', () => { + const { rerender } = render( + + + , + ) + const anchor = screen.getByText('anchor') + fireEvent.mouseEnter(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + rerender( + + + , + ) + // Same DOM node: toggling disabled never remounted the anchor. + expect(screen.getByText('anchor')).toBe(anchor) + fireEvent.mouseEnter(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + }) + + it('drops an already-visible bubble when disabled flips mid-hover', () => { + const { rerender } = render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + expect(screen.getByRole('tooltip')).toBeTruthy() + // e.g. clicking a rail control expands the sidebar: no mouseleave fires. + rerender( + + + , + ) + expect(screen.queryByRole('tooltip')).toBeNull() + }) +}) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index d4d85bf3c0..e87d7fd4df 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -197,6 +197,9 @@ describe('SidebarRoot', () => { vi.useFakeTimers() try { const { onToggleSidebar } = mount(...projectData()) + // While expanded the search control is inert (the row click focuses instead). + act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) + expect(onToggleSidebar).not.toHaveBeenCalled() act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) }) act(() => { vi.advanceTimersByTime(300) }) act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) From 75d37654cb5858ab87060285f1e2950ef0809abe Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:30:01 +0800 Subject: [PATCH 03/14] =?UTF-8?q?fix(gui):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20stale=20smoke=20case,=20tooltip=20trigger=20overlap,=20colla?= =?UTF-8?q?pse=20contract=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The real-browser collapse smoke tracked the old chrome: visible HARNESS text (the wordmark svg is aria-hidden now), an 'Expand sidebar' label (renamed 'Open sidebar'), a 300px settle (default is 280), and an immediate focus assert (rail search defers focus past the slide). The case now tracks the brand span, polls the deferred focus, and uses the current labels and width. - Tooltip treated hover and focus as one trigger: leaving with the mouse dropped the bubble of a still-focused anchor (and vice versa). The two triggers are tracked independently; the bubble hides only after both clear. Spec pins both orders. - The ui-sidebar README and the bilingual collapse note still described the retired geometry morph; both now state the slide + crossfade contract, the fixed-width (never-conceding) sidebar, and the rail's whale-mark/tooltip chrome. --- ...2-collapsed-sidebar-control-rail.i18n.yaml | 4 ++-- ...26-07-22-collapsed-sidebar-control-rail.md | 4 ++-- ...07-22-collapsed-sidebar-control-rail.zh.md | 4 ++-- apps/web/tests/smoke-fixture.e2e.ts | 20 ++++++++-------- packages/client/ui-layout/README.md | 2 +- packages/client/ui-primitives/src/Tooltip.tsx | 17 +++++++++----- .../ui-primitives/tests/tooltip.spec.tsx | 23 +++++++++++++++++++ packages/client/ui-sidebar/README.md | 2 +- 8 files changed, 53 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml index 9d8ccb1790..19e9446f50 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-22-collapsed-sidebar-control-rail.md: e959eef37a9e9c0fea79b82ff970daddd9257609 -2026-07-22-collapsed-sidebar-control-rail.zh.md: 7f6d6529a8aa4a655a1d3292e7f41bfb822f05a3 +2026-07-22-collapsed-sidebar-control-rail.md: 940fcabf126941cc0e411b01c337e45831e442aa +2026-07-22-collapsed-sidebar-control-rail.zh.md: 70ace36fafcb28aa714000262e31c8555d394854 diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md index e959eef37a..940fcabf12 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.md @@ -10,11 +10,11 @@ The sidebar close action persisted a zero width preference, and the layout mappe ## Decision -The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The compact rail participates in the concession solver and retains its right border, while the stored expanded width remains untouched. +The layout maps a closed sidebar (persisted width `0`) to the fixed `SIDEBAR_COLLAPSED` width of 56px: a 24px icon column between the sidebar's 16px horizontal paddings. The sidebar track is fixed-width in the solver — open or collapsed it never concedes to viewport pressure (only details shrinks, then auto-closes) — and the rail retains its right border while the stored expanded width remains untouched. `AppFrame` marks the sidebar collapsed from the persisted width preference rather than from the resolved track width, removes the resize handle while collapsed, and passes `collapsed` to the sidebar slot as owner props from the render site. Collapse and expand animate: the frame transitions `grid-template-columns` (and the remaining handle its `left`) on the deepsuite sider curve — `--ds-ease-in-out` over `--ds-transition-duration-slow`, both supplied by ui-theme's base sheet; transitions pause during drags and under `prefers-reduced-motion`. -`SidebarRoot` reads the owner `collapsed` prop and morphs in place rather than swapping renders: the four control rows persist into the rail — expand toggle, new session, new workspace, search, in the same top-down order as their expanded rows — animating their geometry (heights, paddings, margins, capsule borders) on the same curve, each aligned with its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box). Wide-only content (brand, labels, input, session tree) cross-fades out over 200ms, stays mounted while the collapse animates, and unmounts once the 300ms settle passes — dropping the sessions subscription and leaving the rendered and accessibility trees. The search query lives with the root and survives the round trip. +`SidebarRoot` reads the owner `collapsed` prop and transitions as a slide + crossfade: the expanded content freezes at its width (inline style) and fades out in place over 150ms while the sliding grid column clips it — nothing reflows mid-slide. At settle the wide-only content (brand, labels, input, session tree) unmounts — dropping the sessions subscription and leaving the rendered and accessibility trees — and the control rows snap to the rail (open toggle, new session, new workspace, search, the same top-down order as their expanded rows) fading in as the slide ends. Each rail control keeps its expanded counterpart's behavior (the search icon expands the sidebar and focuses the search box after the slide), carries a tooltip, and the toggle rests as the whale mark with the panel icon on hover. The search query lives with the root and survives the round trip. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md index 7f6d6529a8..70ace36faf 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-22-collapsed-sidebar-control-rail.zh.md @@ -10,11 +10,11 @@ Status: implemented ## 决策 -布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。紧凑控制栏参与空间收缩求解,并保留右侧边框;已存储的展开宽度保持不变。 +布局将关闭的侧边栏(持久化宽度为 `0`)映射为固定的 `SIDEBAR_COLLAPSED` 宽度 56px:在侧边栏两侧各 16px 的水平内边距之间放置一列 24px 的图标控件。侧边栏轨道在求解器中是定宽的——无论展开还是折叠都不向视口压力让步(只有 details 会收缩、继而自动关闭);控制栏保留右侧边框,已存储的展开宽度保持不变。 `AppFrame` 根据持久化的宽度偏好标记侧边栏是否折叠,而不是根据求解后的轨道宽度来判断;折叠时移除尺寸调整手柄,并在渲染点把 `collapsed` 作为 owner props 传给侧边栏插槽。折叠与展开带动画:frame 对 `grid-template-columns`(以及余下手柄的 `left`)应用 deepsuite 侧栏曲线过渡——`--ds-ease-in-out` 配 `--ds-transition-duration-slow`,两个变量由 ui-theme 的 base 表提供;拖拽期间和 `prefers-reduced-motion` 下过渡暂停。 -`SidebarRoot` 读取 owner 的 `collapsed` 属性,原地 morph 而非切换渲染:四个控件行持续存在并演变为控制栏——展开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致——几何(行高、内边距、外边距、胶囊边框)走同一条曲线动画,行为与展开态对应控件对齐(搜索图标会展开侧边栏并聚焦搜索框)。宽态专属内容(品牌标识、文字标签、输入框、会话树)以 200ms 交叉淡出,折叠动画期间保持挂载,300ms settle 后卸载——随之退订会话列表并离开渲染树与可访问性树。搜索关键词由根组件持有,折叠往返后保留。 +`SidebarRoot` 读取 owner 的 `collapsed` 属性,过渡是滑动 + 交叉淡变:展开内容以内联样式冻结在原宽度、150ms 原地淡出,滑动中的网格列裁切它——滑动途中不发生任何重排。settle 时宽态专属内容(品牌标识、文字标签、输入框、会话树)卸载——随之退订会话列表并离开渲染树与可访问性树——控件行落位到控制栏(打开开关、新建会话、新建工作区、搜索,自上而下与展开态各行顺序一致),随滑动结束淡入。每个控制栏控件保持与展开态对应控件一致的行为(搜索图标展开侧边栏并在滑动结束后聚焦搜索框)并带 tooltip;开关静止时显示鲸鱼标,悬停切换为面板图标。搜索关键词由根组件持有,折叠往返后保留。 ## 曾考虑的替代方案 diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index baa33e56ef..9f3998932f 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -149,25 +149,27 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( const settledTrack = async (px: string): Promise => { await expect.poll(firstTrack, { timeout: 2000 }).toBe(px) } + // The brand wordmark is decorative svg (aria-hidden) — presence tracks the wide chrome. + const brand = () => page.locator('[class*="brand"]').count() await page.getByRole('button', { name: 'Collapse sidebar' }).click() // Mid-collapse the wide chrome is still mounted, fading — not swapped out. - expect(await page.locator('text=HARNESS').count()).toBe(1) + expect(await brand()).toBe(1) await settledTrack('56px') - await expect.poll(() => page.locator('text=HARNESS').count(), { timeout: 2000 }).toBe(0) - for (const name of ['Expand sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { + await expect.poll(brand, { timeout: 2000 }).toBe(0) + for (const name of ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']) { await expect(page.getByRole('button', { name }).isVisible(), name).resolves.toBe(true) } - await page.getByRole('button', { name: 'Expand sidebar' }).click() - await settledTrack('300px') + await page.getByRole('button', { name: 'Open sidebar' }).click() + await settledTrack('280px') await expect(page.getByRole('button', { name: 'Collapse sidebar' }).isVisible()).resolves.toBe(true) // Rail search: collapse again, the search control expands and lands in the box. await page.getByRole('button', { name: 'Collapse sidebar' }).click() await settledTrack('56px') await page.getByRole('button', { name: 'Search sessions' }).click() - await settledTrack('300px') - const focused = await page.evaluate(() => - (document.activeElement as HTMLInputElement | null)?.placeholder ?? '') - expect(focused).toContain('Search') + await settledTrack('280px') + // Focus is deferred past the slide (EXPAND_SLIDE_MS) — poll for it. + await expect.poll(() => page.evaluate(() => + (document.activeElement as HTMLInputElement | null)?.placeholder ?? ''), { timeout: 2000 }).toContain('Search') }) it('renders file tool rows and expands fixture reasoning from either click target', async () => { diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index 6cb5fa29a4..9c31e4cc7a 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-layout -Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. A closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5. +Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5. Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'. diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 21191aefb3..f62a397535 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -34,11 +34,14 @@ interface AnchorProps { export function Tooltip({ label, side = 'right', disabled = false, children }: { label: string; side?: TooltipSide; disabled?: boolean; children: ReactElement }) { const anchor = useRef(null) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + // Hover and focus are independent triggers: the bubble hides only after + // BOTH clear (hovering away from a focused anchor must not drop it). + const triggers = useRef({ hover: false, focus: false }) // Disabling mid-hover (e.g. clicking a rail control expands the sidebar) // must drop an already-visible bubble: no mouseleave fires. useEffect(() => { - if (disabled) setPos(null) + if (disabled) { triggers.current = { hover: false, focus: false }; setPos(null) } }, [disabled]) const show = () => { @@ -51,16 +54,18 @@ export function Tooltip({ label, side = 'right', disabled = false, children }: { ? { x: r.right + 10, y: r.top + r.height / 2 } : { x: r.left + r.width / 2, y: r.bottom + 8 }) } - const hide = () => { setPos(null) } + const hide = () => { + if (!triggers.current.hover && !triggers.current.focus) setPos(null) + } return ( <> {cloneElement(children, { ref: anchor, - onMouseEnter: (e) => { children.props.onMouseEnter?.(e); show() }, - onMouseLeave: (e) => { children.props.onMouseLeave?.(e); hide() }, - onFocus: (e) => { children.props.onFocus?.(e); show() }, - onBlur: (e) => { children.props.onBlur?.(e); hide() }, + onMouseEnter: (e) => { children.props.onMouseEnter?.(e); triggers.current.hover = true; show() }, + onMouseLeave: (e) => { children.props.onMouseLeave?.(e); triggers.current.hover = false; hide() }, + onFocus: (e) => { children.props.onFocus?.(e); triggers.current.focus = true; show() }, + onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index c71124040d..3b3af8373c 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -81,6 +81,29 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip')).toBeTruthy() }) + it('keeps the bubble while either hover or focus is still active', () => { + render( + + + , + ) + const anchor = screen.getByText('anchor') + // Focused AND hovered: leaving with the mouse must not drop the bubble. + fireEvent.focus(anchor) + fireEvent.mouseEnter(anchor) + fireEvent.mouseLeave(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + fireEvent.blur(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + // Symmetric: blurring while still hovered keeps it, mouseleave ends it. + fireEvent.mouseEnter(anchor) + fireEvent.focus(anchor) + fireEvent.blur(anchor) + expect(screen.getByRole('tooltip')).toBeTruthy() + fireEvent.mouseLeave(anchor) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + it('drops an already-visible bubble when disabled flips mid-hover', () => { const { rerender } = render( diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 33cdeb756d..7529bfe89c 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse morphs the four control rows into the layout-owned 56px rail (expand / new session / new workspace / search — search expands and focuses the search box) plus the settings foot: geometry animates on the deepsuite curve while wide-only content cross-fades and unmounts at settle. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. From b58f0989f9cfbc8fa2f7218cdf7cac15f5a85d06 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:55:39 +0800 Subject: [PATCH 04/14] refactor(gui): rebuild the client loading kernel as dsh-client-modules with a two-phase boot The module system moves out of dsh-client-runtime (./loader retired) into its own package: a lazy CJS table where executing a bundle only registers its factory and materialization happens at first require, memoized, with recursive requires self-ordering. ClientModuleSystem is a class; index.ts keeps the types and a thin factory. Boot is two-phase: phase one prefetches the immediately tier in parallel (registration only, failures deferred to phase two's loud import); phase two mounts the vendored Loader with the module system as internal, creates one entry per graph row plus the app-shell pseudo-row the kernel appends itself, and settles on an all-ACTIVE sweep. The shell kernel is self-sufficient: hand-rolled loader-status stores, no plugin value imports, platform seed list single- sourced in platform.ts. --- apps/web/src/node-module-stub.ts | 16 + apps/web/tests/smoke-fixture.e2e.ts | 102 ++++--- apps/web/vite.config.ts | 23 +- packages/client/modules/README.md | 20 ++ packages/client/modules/package.json | 37 +++ packages/client/modules/src/index.ts | 175 +++++++++++ packages/client/modules/src/invariant.ts | 34 +++ packages/client/modules/src/loader.ts | 223 ++++++++++++++ packages/client/modules/tsconfig.json | 24 ++ packages/client/runtime/package.json | 8 +- packages/client/runtime/src/client/index.ts | 51 +--- .../client/runtime/src/client/loader/index.ts | 247 --------------- .../runtime/tests/client-loader.spec.ts | 289 ------------------ packages/client/runtime/tsconfig.json | 3 + packages/client/runtime/tsdown.config.ts | 22 +- packages/client/web/README.md | 11 +- packages/client/web/package.json | 8 +- packages/client/web/src/AppRoot.tsx | 36 ++- packages/client/web/src/app-shell.ts | 59 ++++ packages/client/web/src/app.tsx | 17 +- packages/client/web/src/boot.tsx | 201 ++++++++---- packages/client/web/src/index.ts | 14 +- packages/client/web/src/loader-status.ts | 111 +++++++ packages/client/web/src/platform.ts | 20 ++ packages/client/web/src/seed.ts | 24 +- packages/client/web/tests/app-root.spec.tsx | 51 ++-- packages/client/web/tests/boot.spec.tsx | 233 -------------- packages/client/web/tsconfig.json | 15 +- tsconfig.base.json | 3 +- tsconfig.client.json | 2 + 30 files changed, 1064 insertions(+), 1015 deletions(-) create mode 100644 apps/web/src/node-module-stub.ts create mode 100644 packages/client/modules/README.md create mode 100644 packages/client/modules/package.json create mode 100644 packages/client/modules/src/index.ts create mode 100644 packages/client/modules/src/invariant.ts create mode 100644 packages/client/modules/src/loader.ts create mode 100644 packages/client/modules/tsconfig.json delete mode 100644 packages/client/runtime/src/client/loader/index.ts delete mode 100644 packages/client/runtime/tests/client-loader.spec.ts create mode 100644 packages/client/web/src/app-shell.ts create mode 100644 packages/client/web/src/loader-status.ts create mode 100644 packages/client/web/src/platform.ts delete mode 100644 packages/client/web/tests/boot.spec.tsx diff --git a/apps/web/src/node-module-stub.ts b/apps/web/src/node-module-stub.ts new file mode 100644 index 0000000000..c64f307f7c --- /dev/null +++ b/apps/web/src/node-module-stub.ts @@ -0,0 +1,16 @@ +/** + * Browser stand-in for `node:module`, mapped by the vite alias in + * vite.config.ts (design §2.4). The vendored Loader's internal.ts imports + * `createRequire` at module scope but only calls it inside + * `ModuleLoader.fromInternal()`, whose version probe is compiled to the + * `"0.0.0"` define in the browser build — so this throw is a fail-loud + * tripwire for any path that would genuinely need Node's module machinery. + */ + +/** Throwing stand-in for node:module's createRequire (never reached in the browser boot). */ +export const createRequire = (): never => { + throw new Error('node:module is not available in the browser') +} + +/** Erased type peer for the vendored loader's type-only LoadHookContext import. */ +export type LoadHookContext = never diff --git a/apps/web/tests/smoke-fixture.e2e.ts b/apps/web/tests/smoke-fixture.e2e.ts index 9f3998932f..0726d14c8b 100644 --- a/apps/web/tests/smoke-fixture.e2e.ts +++ b/apps/web/tests/smoke-fixture.e2e.ts @@ -1,41 +1,67 @@ -// Keyless boot-chain smoke over the REAL carrier: startWebServer + web-plugins -// registry surface + __DSH_BOOT__ injection + built shell dist in a real -// chromium. First describe: manifest injection + static serving. Second +// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry +// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real +// chromium. First describe: graph injection + the fail-loud half. Second // describe: the settled success pass — all nine REAL tsdown bundles load -// through the DI chain in ?fixture mode, the three-column frame appears in -// one flip, and the resident question completes through the real UI stack. -// The full model round lands in smoke-real under the W5 real-host standard. +// through the module system + vendored Loader chain in ?fixture mode (the +// infrastructure four ride the immediately prefetch tier, the UI rows fetch +// on demand), the three-column frame appears in one flip, and the resident +// question completes through the real UI stack. The full model round lands +// in smoke-real under the W5 real-host standard. import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { startWebServer } from '@deepseek-ai/dsh-host-webserver' -import type { WebPluginBootEntry } from '@deepseek-ai/dsh-host-webserver' +import type { WebBootEntry, WebBootGraph } from '@deepseek-ai/dsh-host-webserver' import { DIST_INDEX, probeFreePort, requireDist, saveFailureShot } from './support.ts' const bundlePath = (dir: string): string => fileURLToPath(new URL(`../../../packages/client/${dir}/lib/client.js`, import.meta.url)) +const LAYOUT_ID = '@deepseek-ai/dsh-client-ui-layout' +const SIDEBAR_ID = '@deepseek-ai/dsh-client-ui-sidebar' + /** id ↔ bundle table for the success pass (the complete Web UI assembly). */ -const REAL_PLUGINS: { id: string; dir: string; inject: string[]; immediately?: boolean }[] = [ - { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', inject: [], immediately: true }, +const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?: boolean }[] = [ + { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', immediately: true }, { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', inject: [], immediately: true }, - { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, - { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, - { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: ['@deepseek-ai/dsh-client-ui-layout'] }, + { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', immediately: true }, + { id: '@deepseek-ai/dsh-client-i18n', dir: 'i18n', immediately: true }, + { id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] }, + { id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] }, + { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] }, { id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] -/** Manifest served by the fake registry: one live bundle row, one missing row. */ -const ROWS: WebPluginBootEntry[] = [ - { id: '@deepseek-ai/dsh-client-ui-layout', url: '/plugins/@deepseek-ai/dsh-client-ui-layout/client.js', inject: [] }, - { id: '@probe/absent', url: '/plugins/@probe/absent/client.js', inject: [] }, -] -const LAYOUT_BUNDLE = bundlePath('ui-layout') +const BUNDLE_PATHS = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)])) + +const row = (id: string, extra?: Partial): WebBootEntry => + ({ id, url: `/plugins/${id}/client.js?rev=e2e`, rev: 'e2e', ...extra }) + +const graphRows: WebBootEntry[] = REAL_PLUGINS.map(p => row(p.id, { + ...(p.inject !== undefined ? { inject: p.inject } : {}), + ...(p.immediately === true ? { immediately: true } : {}), +})) + +/** Graph for the fail-loud half: the immediately tier, one live UI row, one missing row. */ +const FAIL_GRAPH: WebBootGraph = { + rev: 'e2e-fail', + entries: [...graphRows.filter(r => r.immediately === true), row(LAYOUT_ID), row('@probe/absent')], +} + +/** Graph for the success pass: the complete assembly. */ +const OK_GRAPH: WebBootGraph = { rev: 'e2e-ok', entries: graphRows } + +/** Registry stub over a fixed graph (the real HostWebPluginRegistry is webserver-side production code). */ +function fixedRegistry(graph: WebBootGraph, byId: ReadonlyMap) { + return { + graph: () => graph, + clientPath: (id: string) => byId.get(id), + onRebuilt: () => () => undefined, + } +} describe('web boot chain (keyless, real carrier)', () => { let server: Awaited> @@ -52,10 +78,7 @@ describe('web boot chain (keyless, real carrier)', () => { port, distIndex: DIST_INDEX, apiHandler, - webPlugins: { - snapshot: () => ROWS, - clientPath: id => (id === ROWS[0]!.id ? LAYOUT_BUNDLE : undefined), - }, + webPlugins: fixedRegistry(FAIL_GRAPH, BUNDLE_PATHS), }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() page = await browser.newPage() @@ -68,16 +91,25 @@ describe('web boot chain (keyless, real carrier)', () => { await server?.close() }) - it('GET / injects the manifest verbatim', async () => { + it('GET / injects the entry graph verbatim', async () => { onTestFailed(() => saveFailureShot(page, 'smoke-boot-manifest')) const boot = await page.evaluate(() => (window as { __DSH_BOOT__?: unknown }).__DSH_BOOT__) - expect(boot).toEqual({ plugins: ROWS }) + expect(boot).toEqual(FAIL_GRAPH) }) it('serves a real bundle through the plugins endpoint', async () => { - const res = await page.request.get(`${new URL(page.url()).origin}${ROWS[0]!.url}`) + const res = await page.request.get(`${new URL(page.url()).origin}/plugins/${LAYOUT_ID}/client.js`) expect(res.status()).toBe(200) - expect(await res.text()).toContain('window.DSHClientProxy.loadPlugin') + expect(await res.text()).toContain('window.__ModuleLoader__.load') + }) + + it('boots to the loading page and fail-louds the absent entry', async () => { + onTestFailed(() => saveFailureShot(page, 'smoke-boot-fail-loud')) + await page.waitForSelector('text=HARNESS', { timeout: 10_000 }) + await page.waitForSelector('text=Failed to load plugins', { timeout: 10_000 }) + await page.waitForSelector('text=@probe/absent', { timeout: 2000 }) + // The real UI must not have flipped in: the gate opens only on settled. + expect(await page.locator('[class*="frame"]').count()).toBe(0) }) it('applies the token sheets before any plugin CSS', async () => { @@ -87,7 +119,6 @@ describe('web boot chain (keyless, real carrier)', () => { }) describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => { - const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) let server: Awaited> let browser: Browser let page: Page @@ -95,14 +126,9 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( beforeAll(async () => { requireDist() + const missing = REAL_PLUGINS.filter(p => !existsSync(bundlePath(p.dir))) if (missing.length > 0) throw new Error(`client bundles not built (pnpm --filter bundle): ${missing.map(m => m.dir).join(', ')}`) const port = await probeFreePort() - const rows: WebPluginBootEntry[] = REAL_PLUGINS.map((p) => { - const row: WebPluginBootEntry = { id: p.id, url: `/plugins/${p.id}/client.js`, inject: p.inject } - if (p.immediately === true) row.immediately = true - return row - }) - const byId = new Map(REAL_PLUGINS.map(p => [p.id, bundlePath(p.dir)])) // ?fixture never opens HTTP streams; /api is a tripwire like the first describe. const apiHandler = { fetch: () => Promise.resolve(new Response('fixture mode must not call /api', { status: 500 })) } server = await startWebServer({ @@ -110,7 +136,7 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( port, distIndex: DIST_INDEX, apiHandler, - webPlugins: { snapshot: () => rows, clientPath: id => byId.get(id) }, + webPlugins: fixedRegistry(OK_GRAPH, BUNDLE_PATHS), }, (err) => { pageErrors.push(`server: ${String(err)}`) }) browser = await chromium.launch() page = await browser.newPage() @@ -135,8 +161,8 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', ( it('every plugin CSS landed with its ownership tag', async () => { const owners = await page.evaluate(() => [...document.querySelectorAll('style[data-plugin]')].map(s => (s as HTMLElement).dataset['plugin'])) - expect(owners).toContain('@deepseek-ai/dsh-client-ui-layout') - expect(owners).toContain('@deepseek-ai/dsh-client-ui-sidebar') + expect(owners).toContain(LAYOUT_ID) + expect(owners).toContain(SIDEBAR_ID) }) it('collapsed sidebar animates to a 56px rail with the four controls', async () => { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1622371a8a..5a805cbeb3 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -10,17 +10,28 @@ export default defineConfig({ // Workspace packages resolve to SOURCE: package.json exports point at lib // for Node/type consumers, but the browser bundle must compile src directly // so CSS rides vite's pipeline instead of the CSS-externalized lib bundle. - // Only the shell's static surface is aliased — UI plugin packages are NOT - // bundled here; they arrive as dynamic bundles through the client loader. - // Order matters — subpath aliases must win over bare-name prefixes. + // Only the shell's normal-package surface is aliased — plugin packages are + // NEVER bundled here (web2 shell self-sufficiency); they arrive as runtime + // bundles through the client module system. Order matters — subpath + // aliases must win over bare-name prefixes. alias: [ + // Browserization of the vendored cordis Loader: its only node-only + // import; the two process probes are mapped by `define` below. + { find: /^node:module$/, replacement: src('./src/node-module-stub.ts') }, { find: /^@deepseek-ai\/dsh-client-web$/, replacement: src('../../packages/client/web/src/boot.tsx') }, - { find: /^@deepseek-ai\/dsh-client-web-react\/store$/, replacement: src('../../packages/client/web-react/src/store/index.ts') }, { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') }, { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') }, - { find: /^@deepseek-ai\/dsh-client-runtime\/loader$/, replacement: src('../../packages/client/runtime/src/client/loader/index.ts') }, - { find: /^@deepseek-ai\/dsh-client-runtime$/, replacement: src('../../packages/client/runtime/src/index.ts') }, + { find: /^@deepseek-ai\/dsh-client-modules$/, replacement: src('../../packages/client/modules/src/index.ts') }, ], }, + define: { + // vendored loader internal.ts: fromInternal() probes the Node major — + // "0.0.0" takes neither branch, returning undefined (exactly the empty + // internal slot the shell boot fills with the client module loader). + 'process.versions.node': '"0.0.0"', + 'process.execArgv': '[]', + // vendored loader index.ts: envData falls to its default branch. + 'process.env.CORDIS_SHARED': 'undefined', + }, }) diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md new file mode 100644 index 0000000000..234b8406e6 --- /dev/null +++ b/packages/client/modules/README.md @@ -0,0 +1,20 @@ +# @deepseek-ai/dsh-client-modules + +Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` as its `internal` seam — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else. + +Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `/client` and the bare id name the same surface (a plugin bundle IS its package's client half). + +Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook). + +## Model Experience + +None, as the module loader is browser-side kernel machinery; nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Flat module graph by design** — every bundle is one module node whose edges point only at table leaves; the interface (loadCache/edges/invalidate) is shaped for a general module graph so the externalization granularity can change without an interface change. +- **No unload bookkeeping of its own** — style removal and fiber teardown ordering live with the HMR driver (`@deepseek-ai/dsh-client-hmr`); the loader only inventories owned style tag ids per record. diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json new file mode 100644 index 0000000000..ad2fb78ab1 --- /dev/null +++ b/packages/client/modules/package.json @@ -0,0 +1,37 @@ +{ + "name": "@deepseek-ai/dsh-client-modules", + "description": "Client module loader: the browser peer of Node's internal ESM loader, consumed by the vendored cordis Loader as its internal seam (resolve/import/loadCache/invalidate over seed table, static registry and fetch bundles)", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "license": "BSD-3-Clause", + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts new file mode 100644 index 0000000000..cb22dfa7ca --- /dev/null +++ b/packages/client/modules/src/index.ts @@ -0,0 +1,175 @@ +/** + * Client module system: the browser peer of Node's internal ESM loader, built + * as a lazy CJS table. The vendored cordis Loader consumes this object + * through its `internal` seam (the only call site is `EntryTree.import` → + * `internal.import`), which keeps entry governance (fiber lifecycle, inject + * waiting, update/refresh) entirely on the vendored side while this package + * owns code arrival. + * + * Lazy CJS model (web2 §0): executing a plugin bundle only REGISTERS its + * factory (`window.__ModuleLoader__.load({id, factory})`); every module body + * side effect — including CSS injection — lives inside the factory closure + * and runs at materialization, not at script execution. Materialization + * (factory(require) → export surface) happens on first import/require and is + * memoized in {@link ClientModuleLoader.loadCache}; a factory that requires + * another registered-but-unmaterialized module materializes it recursively, + * so load order needs no external sequencing. + * + * Resolution branch order (import): seed word → shell instance; memoized + * record → surface; static registry (shell-own modules, e.g. app-shell) → + * module; registered factory → materialize; graph row → fetch + execute + + * materialize; anything else → throw (loud — the runtime mirror of the + * build-time bundle purity gate). The synchronous `require` handed to + * factories walks the same order minus the fetch branch: fetching is async, + * so only already-executed bundles can be required — and cross-plugin value + * imports are a build error anyway. + * @module @deepseek-ai/dsh-client-modules + */ + +import { ClientModuleLoaderImpl } from './loader.ts' + +export { ClientModuleLoaderImpl } + +declare module 'cordis' { + interface Context { + /** The client module system the web shell provides at boot (contract C5). */ + modules: ClientModuleLoader + } +} + +/** + * One composed client entry pushed by the host (web2 §0 graph row). + * `immediately` marks stage-one prefetch; `inject` is informational graph + * metadata (the authoritative edges live in each package's dshClient + * declaration and reach fibers through entry creation). + * + * Wire contract, held on both sides: the producing peer lives in + * `@deepseek-ai/dsh-host-webserver` (host packages keep zero workspace + * dependencies, so neither side imports the other's shape — drift between + * the two declarations is a bug against the web2 contract). + */ +export interface WebBootEntry { + /** Entry name == package name (or a shell-owned pseudo id, e.g. app-shell). */ + id: string + /** + * Bundle endpoint, '/plugins//client.js?rev='. Absent only on + * shell-owned pseudo rows (app-shell) whose module is statically registered + * — a row that is neither fetchable nor static-registered fails loud. + */ + url?: string + /** Bundle content hash (cache-busting consistency anchor); absent with url. */ + rev?: string + /** Package-name dependency edges, informational (preflight display / HMR diffing). */ + inject?: string[] + /** Stage-one prefetch mark: fetch + execute (factory registration) during module-face boot. */ + immediately?: boolean +} + +/** The composed client entry graph the host injects as `window.__DSH_BOOT__` (dual-held wire contract — see {@link WebBootEntry}). */ +export interface WebBootGraph { + /** Consistency anchor over the whole graph (content + bundle hashes). */ + rev: string + /** Composed entries; order carries no semantics (activation order is fiber inject waiting). */ + entries: WebBootEntry[] +} + +/** The shape a client bundle hands to `window.__ModuleLoader__.load` (registration handoff, contract C6). */ +export interface ClientPluginHandoff { + /** Plugin id (package name) — the registration key; must match the graph row being executed. */ + id: string + /** + * Closure factory holding the whole bundle body: receives the synchronous + * require bound to the module table and returns the bundle's export + * surface. Runs once, at materialization. + */ + factory: (require: (spec: string) => unknown) => Record +} + +/** Window surface this loader owns (bundle side of the handoff protocol) plus the host-injected graph. */ +export interface DshWindow { + /** Host-composed entry graph, injected before the shell bundle runs. */ + __DSH_BOOT__?: WebBootGraph + /** Bundle registration sink; installed once per page by {@link createClientModuleLoader} (contract C6). */ + __ModuleLoader__?: { load(handoff: ClientPluginHandoff): void } +} + +/** Per-module bookkeeping in {@link ClientModuleLoader.loadCache} (module-graph seam, flat today). */ +export interface ClientModuleRecord { + /** Module id (entry name / package name). */ + id: string + /** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */ + surface: unknown + /** Owned `