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.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
<svg
|
||||
width={(size * 182) / 24}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 182 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M68.416 18.2447H67.0501V16.1272H68.416C69.2619 16.1272 70.1166 15.9163 70.6671 15.3304C71.2181 14.7444 71.426 13.8455 71.426 12.9471C71.426 12.0487 71.2268 11.1498 70.6671 10.5643C70.1083 9.97831 69.2619 9.76744 68.416 9.76744C67.5701 9.76744 66.7154 9.97831 66.1639 10.5643C65.6129 11.1503 65.4049 12.0487 65.4049 12.9471V21.6435H63.009V7.6582H65.4049V8.54883H65.8442C65.8918 8.49393 65.9394 8.44728 65.9875 8.40064C66.5871 7.85353 67.5049 7.6582 68.4072 7.6582C69.8212 7.6582 71.2341 8.00998 72.1607 8.98662C73.0868 9.96325 73.4143 11.4632 73.4143 12.9558C73.4143 14.4485 73.0785 15.9406 72.1607 16.925C71.2424 17.9094 69.8212 18.2457 68.416 18.2457V18.2447Z" fill="currentColor"/>
|
||||
<path d="M31.9551 8.03497H33.3204V10.1525H31.9551C31.1087 10.1525 30.2545 10.3633 29.7035 10.9493C29.1525 11.5353 28.945 12.4342 28.945 13.3326C28.945 14.231 29.1447 15.1294 29.7035 15.7154C30.2623 16.3014 31.1087 16.5122 31.9551 16.5122C32.8015 16.5122 33.6562 16.3014 34.2072 15.7154C34.7582 15.1294 34.9657 14.231 34.9657 13.3326V4.62842H37.3611V18.6219H34.9657V17.7313H34.5264C34.4783 17.7857 34.4307 17.8329 34.3826 17.8795C33.7835 18.4261 32.8652 18.6219 31.9629 18.6219C30.5494 18.6219 29.136 18.2707 28.2099 17.294C27.2838 16.3174 26.9563 14.817 26.9563 13.3248C26.9563 11.8327 27.2916 10.34 28.2099 9.35561C29.136 8.37898 30.5494 8.03497 31.9551 8.03497Z" fill="currentColor"/>
|
||||
<path d="M49.3786 13.1431V13.9948H42.9984V12.2996H47.2305C47.1348 11.6825 46.9113 11.1043 46.5119 10.682C45.9371 10.0727 45.0503 9.85409 44.1723 9.85409C43.2943 9.85409 42.4076 10.0727 41.8328 10.682C41.258 11.2913 41.05 12.2213 41.05 13.1435C41.05 14.0658 41.2575 15.003 41.8328 15.6046C42.4076 16.2061 43.2939 16.433 44.1723 16.433C45.0508 16.433 45.9371 16.2143 46.5119 15.6046C46.5916 15.5186 46.6635 15.4248 46.7354 15.331H49.0992C48.8918 16.0657 48.5643 16.7299 48.0691 17.2454C47.111 18.2531 45.6339 18.6205 44.1723 18.6205C42.7108 18.6205 41.2337 18.2609 40.2755 17.2454C39.3174 16.2299 38.9661 14.6828 38.9661 13.1435C38.9661 11.6043 39.3096 10.0494 40.2755 9.04168C41.242 8.03396 42.7108 7.66663 44.1723 7.66663C45.6339 7.66663 47.111 8.02618 48.0691 9.04168C49.0351 10.0572 49.3786 11.6043 49.3786 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M61.4045 13.1431V13.9948H55.0243V12.2996H59.2564C59.1602 11.6825 58.9372 11.1043 58.5378 10.682C57.963 10.0727 57.0762 9.85409 56.1982 9.85409C55.3202 9.85409 54.4335 10.0727 53.8587 10.682C53.2839 11.2913 53.0759 12.2213 53.0759 13.1435C53.0759 14.0658 53.2834 15.003 53.8587 15.6046C54.4335 16.2061 55.3202 16.433 56.1982 16.433C57.0762 16.433 57.963 16.2143 58.5378 15.6046C58.6179 15.5186 58.6894 15.4248 58.7608 15.331H61.1251C60.9171 16.0657 60.5897 16.7299 60.0945 17.2454C59.1364 18.2531 57.6593 18.6205 56.1982 18.6205C54.7372 18.6205 53.2596 18.2609 52.3014 17.2454C51.3432 16.2299 50.9919 14.6828 50.9919 13.1435C50.9919 11.6043 51.3355 10.0494 52.3014 9.04168C53.2678 8.03396 54.7367 7.66663 56.1982 7.66663C57.6598 7.66663 59.1364 8.02618 60.0945 9.04168C61.061 10.0572 61.4045 11.6043 61.4045 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M80.242 18.6214C81.7035 18.6214 83.1801 18.4105 84.1383 17.809C85.0965 17.2075 85.4482 16.2931 85.4482 15.3869C85.4482 14.4807 85.1042 13.5585 84.1383 12.9647C83.1801 12.371 81.703 12.1518 80.242 12.1518C79.6186 12.1518 79.0438 12.0658 78.6366 11.8394C78.2294 11.6047 78.0778 11.2534 78.0778 10.9017C78.0778 10.5499 78.2216 10.1908 78.6366 9.9639C79.0438 9.72921 79.6749 9.65147 80.2973 9.65147C80.9198 9.65147 81.5509 9.73747 81.9591 9.9639C82.3663 10.1986 82.5179 10.5499 82.5179 10.9017H84.9531C84.9531 9.99499 84.6421 9.07327 83.7719 8.47951C82.9017 7.88576 81.5679 7.66663 80.2424 7.66663C78.9169 7.66663 77.5837 7.8775 76.713 8.47951C75.8427 9.08104 75.5308 9.99499 75.5308 10.9017C75.5308 11.8083 75.8423 12.73 76.713 13.3238C77.5832 13.9176 78.9165 14.1367 80.2424 14.1367C80.929 14.1367 81.688 14.2227 82.1428 14.4491C82.5985 14.676 82.7579 15.0351 82.7579 15.3869C82.7579 15.7387 82.5985 16.0977 82.1428 16.3246C81.688 16.5511 80.9931 16.6371 80.3066 16.6371C79.62 16.6371 78.9169 16.5511 78.4694 16.3246C78.0224 16.0982 77.8543 15.7387 77.8543 15.3869H75.0435C75.0435 16.2935 75.3865 17.2153 76.3534 17.809C77.3194 18.4028 78.7809 18.6214 80.2424 18.6214H80.242Z" fill="currentColor"/>
|
||||
<path d="M97.4733 13.1431V13.9948H91.0932V12.2996H95.3252C95.23 11.6825 95.006 11.1043 94.6071 10.682C94.0313 10.0727 93.1456 9.85409 92.2666 9.85409C91.3876 9.85409 90.5018 10.0727 89.927 10.682C89.3522 11.2913 89.1452 12.2213 89.1452 13.1435C89.1452 14.0658 89.3522 15.003 89.927 15.6046C90.5018 16.2061 91.3886 16.433 92.2666 16.433C93.1446 16.433 94.0313 16.2143 94.6071 15.6046C94.6863 15.5186 94.7587 15.4248 94.8301 15.331H97.1935C96.9855 16.0657 96.6585 16.7299 96.1639 17.2454C95.2057 18.2531 93.7281 18.6205 92.2666 18.6205C90.805 18.6205 89.3284 18.2609 88.3703 17.2454C87.4121 16.2299 87.0613 14.6828 87.0613 13.1435C87.0613 11.6043 87.4043 10.0494 88.3703 9.04168C89.3367 8.03396 90.806 7.66663 92.2666 7.66663C93.7272 7.66663 95.2057 8.02618 96.1639 9.04168C97.1298 10.0572 97.4729 11.6043 97.4729 13.1435L97.4733 13.1431Z" fill="currentColor"/>
|
||||
<path d="M109.499 13.1431V13.9948H103.119V12.2996H107.351C107.256 11.6825 107.032 11.1043 106.632 10.682C106.057 10.0727 105.172 9.85409 104.293 9.85409C103.414 9.85409 102.528 10.0727 101.953 10.682C101.378 11.2913 101.17 12.2213 101.17 13.1435C101.17 14.0658 101.378 15.003 101.953 15.6046C102.528 16.2061 103.415 16.433 104.293 16.433C105.171 16.433 106.057 16.2143 106.632 15.6046C106.712 15.5186 106.784 15.4248 106.856 15.331H109.22C109.012 16.0657 108.685 16.7299 108.19 17.2454C107.231 18.2531 105.754 18.6205 104.293 18.6205C102.831 18.6205 101.355 18.2609 100.396 17.2454C99.4382 16.2299 99.0864 14.6828 99.0864 13.1435C99.0864 11.6043 99.4295 10.0494 100.396 9.04168C101.362 8.03396 102.832 7.66663 104.293 7.66663C105.754 7.66663 107.231 8.02618 108.19 9.04168C109.156 10.0572 109.499 11.6043 109.499 13.1435V13.1431Z" fill="currentColor"/>
|
||||
<path d="M113.5 4.62817H111.104V18.6217H113.5V4.62817Z" fill="currentColor"/>
|
||||
<path d="M117.589 12.8154L121.517 18.6208H118.554L114.625 12.8154L118.554 8.15088H121.517L117.589 12.8154Z" fill="currentColor"/>
|
||||
<g clipPath="url(#dsh-wordmark-whale-clip)">
|
||||
<path d="M23.0584 4.95203C22.8129 4.83203 22.7074 5.06103 22.5639 5.17704C22.5149 5.21454 22.4734 5.26354 22.4319 5.30854C22.0734 5.69155 21.6543 5.94306 21.1073 5.91306C20.3073 5.86806 19.6243 6.11957 19.0203 6.73158C18.8918 5.97706 18.4652 5.52655 17.8162 5.23754C17.4767 5.08753 17.1332 4.93703 16.8952 4.61052C16.7292 4.37801 16.6837 4.11901 16.6007 3.8635C16.5477 3.70949 16.4952 3.55199 16.3177 3.52549C16.1252 3.49549 16.0497 3.65699 15.9742 3.792C15.6722 4.34401 15.5552 4.95203 15.5667 5.56805C15.5932 6.95359 16.1782 8.05712 17.3407 8.84215C17.4727 8.93215 17.5067 9.02215 17.4652 9.15366C17.3857 9.42416 17.2917 9.68667 17.2087 9.95718C17.1557 10.1297 17.0767 10.1677 16.8917 10.0922C16.2537 9.82568 15.7027 9.43117 15.2156 8.95465C14.3891 8.15513 13.6416 7.2726 12.7096 6.58158C12.4906 6.42007 12.2716 6.27007 12.045 6.12707C11.094 5.20354 12.1696 4.44502 12.4186 4.35501C12.6791 4.26101 12.5091 3.938 11.6675 3.942C10.826 3.9455 10.056 4.22751 9.07446 4.60302C8.93096 4.65952 8.77995 4.70052 8.62545 4.73452C7.73492 4.56552 6.80989 4.52802 5.84386 4.63702C4.02481 4.83953 2.57177 5.69955 1.50373 7.1676C0.220694 8.93215 -0.0813148 10.9372 0.288196 13.0283C0.676708 15.2323 1.80174 17.0569 3.53029 18.4834C5.32285 19.9625 7.38741 20.6875 9.74298 20.5485C11.1735 20.466 12.7661 20.2745 14.5626 18.7539C15.0156 18.9795 15.4912 19.0695 16.2797 19.137C16.8872 19.1935 17.4722 19.107 17.9252 19.013C18.6347 18.8629 18.5857 18.2059 18.3292 18.0854C16.2497 17.1169 16.7062 17.5109 16.2912 17.1919C17.3477 15.9419 18.9618 13.7198 19.4598 10.6942C19.5088 10.3602 19.5713 9.88968 19.5638 9.61917C19.5598 9.45417 19.5978 9.39016 19.7863 9.37116C20.3073 9.31116 20.8128 9.16866 21.2773 8.91315C22.6249 8.17713 23.1684 6.96809 23.2964 5.51905C23.3154 5.29754 23.2924 5.06853 23.0584 4.95203ZM11.3165 17.9954C9.30097 16.4109 8.32344 15.8894 7.91992 15.9119C7.54241 15.9344 7.61042 16.3664 7.69342 16.6479C7.78042 16.9259 7.89342 17.1174 8.05193 17.3614C8.16143 17.5229 8.23694 17.7629 7.94243 17.9434C7.29341 18.3449 6.16487 17.8084 6.11187 17.7819C4.79833 17.0084 3.7003 15.9874 2.92628 14.5908C2.17875 13.2468 1.74474 11.8047 1.67324 10.2657C1.65424 9.89418 1.76374 9.76267 2.13375 9.69517C2.62077 9.60517 3.12278 9.58617 3.6093 9.65767C5.66636 9.95818 7.41741 10.8777 8.88545 12.3348C9.72348 13.1643 10.3575 14.1558 11.0105 15.1243C11.705 16.1529 12.4521 17.1329 13.4036 17.9364C13.7396 18.2179 14.0076 18.4319 14.2641 18.5899C13.4906 18.6764 12.1996 18.6949 11.3165 17.9964V17.9954ZM12.2826 11.7817C12.2826 11.6167 12.4146 11.4852 12.5806 11.4852C12.6181 11.4852 12.6521 11.4927 12.6826 11.5037C12.7241 11.5187 12.7621 11.5412 12.7921 11.5752C12.8451 11.6277 12.8751 11.7027 12.8751 11.7817C12.8751 11.9467 12.7431 12.0782 12.5771 12.0782C12.4111 12.0782 12.2826 11.9467 12.2826 11.7817ZM15.2831 13.3208C15.0906 13.3998 14.8981 13.4673 14.7131 13.4748C14.4261 13.4898 14.1131 13.3733 13.9431 13.2308C13.6791 13.0093 13.4901 12.8853 13.4111 12.4988C13.3771 12.3338 13.3961 12.0782 13.4261 11.9317C13.4941 11.6162 13.4186 11.4137 13.1961 11.2297C13.0151 11.0797 12.7846 11.0382 12.5316 11.0382C12.4371 11.0382 12.3506 10.9967 12.2861 10.9632C12.1806 10.9107 12.0936 10.7792 12.1766 10.6177C12.2031 10.5652 12.3316 10.4377 12.3616 10.4152C12.7051 10.2197 13.1011 10.2837 13.4676 10.4302C13.8071 10.5692 14.0641 10.8242 14.4336 11.1847C14.8111 11.6202 14.8791 11.7402 15.0941 12.0672C15.2641 12.3228 15.4186 12.5853 15.5247 12.8858C15.5887 13.0733 15.5057 13.2268 15.2831 13.3208Z" fill="currentColor"/>
|
||||
</g>
|
||||
<rect x="129.348" y="5.5" width="52" height="14" rx="2" fill="currentColor"/>
|
||||
<g clipPath="url(#dsh-wordmark-badge-clip)">
|
||||
<path d="M132.848 8.93205H134.08V16.137H132.848V8.93205ZM136.5 8.93205H137.732V16.137H136.5V8.93205ZM133.365 13.024V11.99H137.193V13.024H133.365Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M140.397 14.432L140.672 13.453H143.202L143.532 14.432H140.397ZM140.287 16.137H139.055L141.277 8.93205H142.201L142.146 9.74605L140.947 13.915H140.969L140.287 16.137ZM145.039 16.137H143.741L143.07 13.948L143.081 13.937L141.871 9.74605L141.926 8.93205H142.817L145.039 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M146.846 8.93205H149.068C149.852 8.93205 150.443 9.11538 150.839 9.48205C151.235 9.84138 151.433 10.3327 151.433 10.956C151.433 11.22 151.396 11.4657 151.323 11.693C151.249 11.9204 151.125 12.1257 150.949 12.309C150.773 12.4924 150.531 12.65 150.223 12.782C149.922 12.9067 149.541 13.0057 149.079 13.079V13.321H146.846V12.639L148.023 12.485C148.631 12.4044 149.09 12.298 149.398 12.166C149.706 12.034 149.915 11.8764 150.025 11.693C150.135 11.5024 150.19 11.2934 150.19 11.066C150.19 10.6994 150.083 10.417 149.871 10.219C149.658 10.021 149.324 9.92205 148.87 9.92205H146.846V8.93205ZM146.395 8.93205H147.627V16.137H146.395V8.93205ZM151.917 16.093V16.137H150.366L149.024 14.322C148.87 14.1094 148.73 13.9407 148.606 13.816C148.481 13.684 148.345 13.5887 148.199 13.53C148.052 13.464 147.872 13.42 147.66 13.398C147.447 13.3687 147.176 13.3504 146.846 13.343V13.145H149.079C149.233 13.211 149.368 13.2844 149.486 13.365C149.61 13.4457 149.735 13.5447 149.86 13.662C149.992 13.7794 150.138 13.937 150.3 14.135L151.917 16.093Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M153.58 9.57005L153.591 8.93205H154.46L157.584 15.51V16.137H156.704L153.58 9.57005ZM158.024 16.137H156.968L156.88 8.93205H158.024V16.137ZM154.24 16.137H153.096V8.93205H154.152L154.24 16.137Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M159.963 8.93205H161.206V16.137H159.963V8.93205ZM160.095 9.96605V8.93205H164.858V9.96605H160.095ZM160.095 16.137V15.103H164.902V16.137H160.095ZM160.095 13.013V11.99H164.374V13.013H160.095Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M169.052 15.257C169.543 15.257 169.895 15.1654 170.108 14.982C170.328 14.7987 170.438 14.5457 170.438 14.223C170.438 14.047 170.405 13.8967 170.339 13.772C170.273 13.6474 170.152 13.5337 169.976 13.431C169.807 13.321 169.558 13.2147 169.228 13.112L168.491 12.881C167.846 12.6757 167.38 12.4044 167.094 12.067C166.808 11.7297 166.665 11.3007 166.665 10.78C166.665 10.428 166.76 10.1017 166.951 9.80105C167.142 9.50038 167.428 9.25838 167.809 9.07505C168.19 8.89172 168.663 8.80005 169.228 8.80005C169.631 8.80005 169.998 8.82938 170.328 8.88805C170.665 8.93938 171.039 9.01638 171.45 9.11905L171.274 10.175C170.834 10.0504 170.442 9.96238 170.097 9.91105C169.76 9.85238 169.463 9.82305 169.206 9.82305C168.737 9.82305 168.403 9.90738 168.205 10.076C168.007 10.2374 167.908 10.439 167.908 10.681C167.908 10.857 167.941 11.0147 168.007 11.154C168.073 11.286 168.19 11.407 168.359 11.517C168.535 11.627 168.784 11.7334 169.107 11.836L169.866 12.078C170.526 12.276 170.995 12.5327 171.274 12.848C171.553 13.156 171.692 13.585 171.692 14.135C171.692 14.5604 171.589 14.9344 171.384 15.257C171.179 15.5797 170.878 15.8327 170.482 16.016C170.093 16.1994 169.609 16.291 169.03 16.291C168.627 16.291 168.212 16.247 167.787 16.159C167.362 16.071 166.9 15.9427 166.401 15.774L166.665 14.718C167.156 14.894 167.6 15.0297 167.996 15.125C168.399 15.213 168.751 15.257 169.052 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
<path d="M175.809 15.257C176.3 15.257 176.652 15.1654 176.865 14.982C177.085 14.7987 177.195 14.5457 177.195 14.223C177.195 14.047 177.162 13.8967 177.096 13.772C177.03 13.6474 176.909 13.5337 176.733 13.431C176.564 13.321 176.315 13.2147 175.985 13.112L175.248 12.881C174.603 12.6757 174.137 12.4044 173.851 12.067C173.565 11.7297 173.422 11.3007 173.422 10.78C173.422 10.428 173.517 10.1017 173.708 9.80105C173.899 9.50038 174.185 9.25838 174.566 9.07505C174.947 8.89172 175.42 8.80005 175.985 8.80005C176.388 8.80005 176.755 8.82938 177.085 8.88805C177.422 8.93938 177.796 9.01638 178.207 9.11905L178.031 10.175C177.591 10.0504 177.199 9.96238 176.854 9.91105C176.517 9.85238 176.22 9.82305 175.963 9.82305C175.494 9.82305 175.16 9.90738 174.962 10.076C174.764 10.2374 174.665 10.439 174.665 10.681C174.665 10.857 174.698 11.0147 174.764 11.154C174.83 11.286 174.947 11.407 175.116 11.517C175.292 11.627 175.541 11.7334 175.864 11.836L176.623 12.078C177.283 12.276 177.752 12.5327 178.031 12.848C178.31 13.156 178.449 13.585 178.449 14.135C178.449 14.5604 178.346 14.9344 178.141 15.257C177.936 15.5797 177.635 15.8327 177.239 16.016C176.85 16.1994 176.366 16.291 175.787 16.291C175.384 16.291 174.969 16.247 174.544 16.159C174.119 16.071 173.657 15.9427 173.158 15.774L173.422 14.718C173.913 14.894 174.357 15.0297 174.753 15.125C175.156 15.213 175.508 15.257 175.809 15.257Z" fill="var(--dsw-alias-label-primary-inverted)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="dsh-wordmark-whale-clip">
|
||||
<rect width="23.16" height="17.0435" fill="white" transform="translate(0.141602 3.52185)"/>
|
||||
</clipPath>
|
||||
<clipPath id="dsh-wordmark-badge-clip">
|
||||
<rect width="46" height="14" fill="white" transform="translate(132.348 5.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => { onToggleSidebar() }}
|
||||
>
|
||||
<IconPanelLeftOutline16 />
|
||||
</button>
|
||||
{/* 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>
|
||||
|
||||
<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 label="New session" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label="New session"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconNewChatOutline16 size={wide ? 14 : 18} />
|
||||
{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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user