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:
Yif
2026-07-23 17:08:47 +08:00
parent 9c01696a7e
commit 20720ef238
17 changed files with 523 additions and 272 deletions

View File

@@ -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);
}

View File

@@ -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>
)
}

View File

@@ -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 }
}

View File

@@ -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])
})
})

View File

@@ -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 })
})