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; +}