Преглед изворни кода

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.
Yif пре 1 месец
родитељ
комит
20720ef238

+ 15 - 1
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);
 }

+ 5 - 4
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -34,8 +34,8 @@ function DetailsColumn(props: { children?: ReactNode }) {
   return <div className={css.detailsCol}>{props.children}</div>
 }
 
-/** 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
     <div
       className={css.handle}
       style={{ left: props.left }}
+      data-side={props.side}
       data-dragging={dragging || undefined}
       onPointerDown={onPointerDown}
       onPointerMove={onPointerMove}
@@ -161,8 +162,8 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
         )}
       </SessionProvider>
       {/* The collapsed rail is fixed-width: no resize handle while closed. */}
-      {panels.sidebar > 0 && <DragHandle left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
-      {cols.details > 0 && <DragHandle left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
+      {panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
+      {cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
     </div>
   )
 }

+ 20 - 31
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 }
 }

+ 15 - 15
packages/client/ui-layout/tests/app-frame.spec.tsx

@@ -50,7 +50,7 @@ function hookOf<T>(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])
   })
 })

+ 15 - 26
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 })
   })

Разлика између датотеке није приказан због своје велике величине
+ 33 - 0
packages/client/ui-primitives/src/BrandWordmark.tsx


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

+ 72 - 0
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<HTMLElement> | 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<AnchorProps> }) {
+  const anchor = useRef<HTMLElement | null>(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 && (
+        <span className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
+          {label}
+        </span>
+      )}
+    </>
+  )
+}

+ 13 - 3
packages/client/ui-primitives/src/icons/index.tsx

@@ -165,6 +165,16 @@ export const IconChevronRightOutline14 = ({ size = 14, className }: IconProps) =
   </svg>
 )
 
+/** 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) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path
+      d="M4.25 2.82782L4.25 11.1722C4.25 11.6622 4.84243 11.9076 5.18891 11.5611L9.36109 7.38891C9.57588 7.17412 9.57588 6.82588 9.36109 6.61109L5.18891 2.43891C4.84243 2.09243 4.25 2.33782 4.25 2.82782Z"
+      fill="currentColor"
+    />
+  </svg>
+)
+
 /** ic_ds_chevron_up_outline_14 */
 export const IconChevronUpOutline14 = ({ size = 14, className }: IconProps) => (
   <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -552,11 +562,11 @@ export const IconProjectAddOutline16 = ({ size = 16, className }: IconProps) =>
     </svg>
 )
 
-/** 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) => (
   <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none">
-    <path transform="translate(0.5996 1.645)" d="M4.69624 0C5.3113 0.000140941 5.88623 0.307626 6.22749 0.819336L6.69917 1.52734C6.78449 1.65523 6.92823 1.7324 7.08198 1.73242L11.6699 1.73242C13.0038 1.73257 14.0859 2.81452 14.0859 4.14844L14.0859 5.05566C14.7693 5.4559 15.1595 6.2791 14.9374 7.11621L13.8837 11.0869C13.6026 12.1454 12.644 12.8818 11.5488 12.8818L2.41596 12.8818C1.01395 12.8816 -0.0511855 11.7074 0.00190073 10.376L0.00190073 2.41602C0.00190073 1.08201 1.08391 0 2.41792 0L4.69624 0ZM3.27827 6.18457C2.80902 6.18474 2.39772 6.50054 2.27729 6.9541L1.41499 10.2012C1.2407 10.8579 1.73653 11.5017 2.41596 11.502L11.5488 11.502C12.0182 11.502 12.4293 11.1861 12.5498 10.7324L13.6035 6.7627C13.681 6.47081 13.4611 6.18474 13.1591 6.18457L3.27827 6.18457ZM2.41792 1.38086C1.8462 1.38086 1.38276 1.8443 1.38276 2.41602L1.38276 5.72266C1.83056 5.15603 2.52166 4.80383 3.27827 4.80371L12.705 4.80371L12.705 4.14844C12.705 3.57681 12.2415 3.11342 11.6699 3.11328L7.08198 3.11328C6.46674 3.11326 5.89205 2.80484 5.55073 2.29297L5.07905 1.58496C4.99378 1.45723 4.84981 1.381 4.69624 1.38086L2.41792 1.38086Z" fill="currentColor"/>
-    <path transform="translate(1.979 3.026)" d="M11.7793 4.80371C12.0811 4.80388 12.3008 5.09009 12.2236 5.38184L11.1699 9.35156C11.0494 9.80525 10.6383 10.1211 10.1689 10.1211L1.03612 10.1211C0.356864 10.1206 -0.139141 9.47695 0.0351403 8.82031L0.897445 5.57324C1.01797 5.12 1.42946 4.80406 1.89842 4.80371L11.7793 4.80371ZM3.31639 0C3.46985 0.000107244 3.61388 0.0765707 3.6992 0.204102L4.17088 0.912109C4.51213 1.42391 5.08701 1.73228 5.70213 1.73242L10.29 1.73242C10.8616 1.73251 11.325 2.19605 11.3252 2.76758L11.3252 3.42285L1.89842 3.42285C1.14203 3.42309 0.450638 3.77535 0.00291371 4.3418L0.00291371 1.03516C0.00307753 0.463694 0.466614 0.000188756 1.03807 0L3.31639 0Z" fill="currentColor"/>
+    <path d="M5.19629 1.57104C5.81144 1.5711 6.38623 1.8786 6.72754 2.39038L7.19922 3.09839C7.28454 3.22635 7.42824 3.30344 7.58203 3.30347H12.1699C13.5039 3.30348 14.5859 4.38548 14.5859 5.71948V6.62671C15.2694 7.02689 15.6605 7.85012 15.4385 8.68726L14.3848 12.658C14.1037 13.7164 13.1449 14.4527 12.0498 14.4529H2.91699C1.51651 14.4529 0.451662 13.2814 0.501954 11.9519V3.98706C0.501954 2.65305 1.58396 1.57104 2.91797 1.57104H5.19629ZM3.7793 7.75562C3.30994 7.75562 2.89883 8.07153 2.77832 8.52515L1.91602 11.7722C1.74167 12.4291 2.23734 13.073 2.91699 13.073H12.0498C12.5191 13.0728 12.9304 12.757 13.0508 12.3035L14.1045 8.33374C14.1819 8.04202 13.9619 7.756 13.6602 7.75562H3.7793ZM2.91797 2.9519C2.34625 2.9519 1.88281 3.41534 1.88281 3.98706V7.2937C2.33068 6.7269 3.02249 6.37476 3.7793 6.37476H13.2051V5.71948C13.2051 5.14777 12.7416 4.68434 12.1699 4.68433H7.58203C6.96675 4.6843 6.39209 4.37595 6.05078 3.86401L5.5791 3.15601C5.49379 3.02821 5.34995 2.95196 5.19629 2.9519H2.91797Z" fill="currentColor"/>
+    <path opacity="0.2" d="M13.6602 7.75525C13.9618 7.7556 14.1815 8.04179 14.1045 8.33337L13.0508 12.3031C12.9304 12.7567 12.5191 13.0725 12.0498 13.0726H2.91701C2.23744 13.0725 1.7417 12.4287 1.91603 11.7719L2.77834 8.52478C2.89898 8.07146 3.31018 7.75532 3.77931 7.75525H13.6602ZM5.1963 2.95154C5.34985 2.95159 5.49377 3.02803 5.57912 3.15564L6.0508 3.86365C6.39205 4.37553 6.96685 4.68385 7.58205 4.68396H12.1699C12.7416 4.68396 13.2049 5.14754 13.2051 5.71912V6.37439H3.77931C3.02267 6.37444 2.33067 6.72671 1.88283 7.29333V3.98669C1.88299 3.4152 2.34649 2.95168 2.91798 2.95154H5.1963Z" fill="currentColor"/>
     </svg>
 )
 

+ 3 - 0
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'

+ 2 - 2
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 => {

+ 58 - 12
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;
+  flex: none;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
   width: 16px;
   height: 20px;
+  border: none;
+  border-radius: 4px;
+  padding: 0;
+  background: transparent;
+  cursor: pointer;
 }
 
-/* "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);
+.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;
+  }
+}

+ 9 - 16
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 ? <IconFolderOpen16 /> : <IconFolderClose16 />}
       </span>
       <span className={clsx(css.slot, css.chevron)}>
-        {row.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
+        <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
       </span>
       <span className={css.projectText}>
         <span className={css.title}>{row.label}</span>
@@ -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 (
     <div
       className={clsx(css.sessionRow, selected && css.selected)}
       role="treeitem"
       aria-selected={selected}
       {...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
-      style={{ paddingLeft: 8 + Math.max(0, row.depth - 1) * INDENT_STEP }}
+      style={{ paddingLeft: 8 + row.depth * INDENT_STEP }}
       onClick={onOpen}
     >
       {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 ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
+              <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
             </button>
           )
         : <span className={css.slot} />}
       <span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
-      {row.depth > 0 && (
-        <span className={css.cornerSlot} data-tree-corner="">
-          <IconTreeCorner8x10 />
-        </span>
-      )}
       <span className={css.title}>{row.title}</span>
       <span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
       <span className={css.rowActions}>

+ 95 - 100
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -1,38 +1,59 @@
-/* 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; }
 }
 
-@keyframes wide-in {
+/* 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; }
 }
 
@@ -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;
+}
+
+/* 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;
 }
 
-/* New Session: 38px capsule (figma 133:7634) morphing into the rail's plain
-   icon control — border and fill fade with the label. */
+.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;
   }

+ 81 - 51
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 (
-    <div className={clsx(css.root, collapsed && css.collapsed)}>
+    <div
+      className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
+      style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
+    >
       <div className={css.logoRow}>
         {wide && (
           <span className={clsx(css.brand, css.wide)}>
-            {/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
-            <FishLogo size={23} />
-            <span className={css.wordmark}>deepseek</span>
-            <span className={css.badge}>HARNESS</span>
+            <BrandWordmark />
           </span>
         )}
+        {/* Rail resting state is the whale mark; hovering swaps in the panel
+            icon (the expand affordance, figma sidebar-hover flow). */}
+        <Tooltip label="Open sidebar" disabled={wide}>
+          <button
+            type="button"
+            className={clsx(css.iconButton, css.toggle)}
+            aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
+            onClick={() => { onToggleSidebar() }}
+          >
+            {!wide && <FishLogo className={css.railFish} size={24} />}
+            {/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
+            <IconPanelLeftOutline16 className={css.panelIcon} size={wide ? 16 : 18} />
+          </button>
+        </Tooltip>
+      </div>
+
+      <Tooltip label="New session" disabled={wide}>
         <button
           type="button"
-          className={css.iconButton}
-          aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
-          onClick={() => { onToggleSidebar() }}
+          className={css.newSession}
+          aria-label="New session"
+          onClick={() => { onCreate() }}
         >
-          <IconPanelLeftOutline16 />
+          <IconNewChatOutline16 size={wide ? 14 : 18} />
+          {wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
         </button>
-      </div>
-
-      <button
-        type="button"
-        className={css.newSession}
-        aria-label="New session"
-        onClick={() => { onCreate() }}
-      >
-        <IconNewChatOutline16 size={14} />
-        {wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
-      </button>
+      </Tooltip>
 
       <div className={css.sectionHeader}>
         {wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
         {wide && <GroupByMenu />}
-        <button
-          type="button"
-          className={css.iconButton}
-          aria-label="New workspace"
-          onClick={() => { onCreate() }}
-        >
-          <IconProjectAddOutline16 />
-        </button>
+        <Tooltip label="New Workspace" disabled={wide}>
+          <button
+            type="button"
+            className={css.iconButton}
+            aria-label="New workspace"
+            onClick={() => { onCreate() }}
+          >
+            <IconProjectAddOutline16 size={wide ? 16 : 18} />
+          </button>
+        </Tooltip>
       </div>
 
       {/* Expanded: the row is a click-to-focus field (the leading icon is
           decorative). Collapsed: the icon is the rail's search control. */}
       <div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
-        <button
-          type="button"
-          className={css.searchButton}
-          aria-label="Search sessions"
-          tabIndex={collapsed ? 0 : -1}
-          onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
-        >
-          <IconSearchOutline16 size={14} />
-        </button>
+        <Tooltip label="Search" disabled={wide}>
+          <button
+            type="button"
+            className={css.searchButton}
+            aria-label="Search sessions"
+            tabIndex={collapsed ? 0 : -1}
+            onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
+          >
+            <IconSearchOutline16 size={wide ? 14 : 18} />
+          </button>
+        </Tooltip>
         {wide && (
           <input
             ref={searchInput}
@@ -245,7 +275,7 @@ export function SidebarRoot({ collapsed, useSessions, onOpen, onCreate, onToggle
       </div>
 
       <div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
-        <IconSettingsOutline14 />
+        <IconSettingsOutline14 size={wide ? 14 : 18} />
         {wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
       </div>
     </div>

+ 12 - 7
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()

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

Неке датотеке нису приказане због велике количине промена