From 96aa7ebbf2b6661b965c4edd3ebea45374a2a2ef Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 3 Sep 2026 13:23:28 -0700 Subject: [PATCH] [eric] dock: the tile floor is 20px, the column snaps to whole tiles, and each edge says how many chats are past it and pages on click Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01C9zwUaHucUgrdxvK8FvjYT --- .../pages/Dashboard/desktop/DesktopDock.tsx | 47 ++++++++++++++----- .../Dashboard/desktop/dockOverflow.test.ts | 30 ++++++++++++ .../Dashboard/desktop/useDockLayout.test.ts | 3 +- .../pages/Dashboard/desktop/useDockLayout.ts | 14 +++++- 4 files changed, 79 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/dockOverflow.test.ts diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 60725fb4..2d57d3b3 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -10,7 +10,7 @@ import { getWebview } from '@/shared/browserRegistry'; import { buildDockEntries, CardRect, DockEntry } from './dockEntries'; import { openCardContextMenu } from './openCardContextMenu'; import { dockTileMenuRows } from './dockTileMenuRows'; -import { useDockLayout } from './useDockLayout'; +import { hiddenCounts, useDockLayout } from './useDockLayout'; import { DockTileIcon } from './DockTileIcon'; import DockActionTiles, { DOCK_ACTION_COUNT } from './DockActionTiles'; import DockHoverPreview from './DockHoverPreview'; @@ -53,7 +53,7 @@ function DesktopDock({ const accent = useClaudeTokens().accent.primary; const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null); const [liveShot, setLiveShot] = useState<{ id: string; dataUrl: string } | null>(null); - const [edges, setEdges] = useState<{ top: boolean; bottom: boolean }>({ top: false, bottom: false }); + const [edges, setEdges] = useState<{ top: boolean; bottom: boolean; above: number; below: number }>({ top: false, bottom: false, above: 0, below: 0 }); const hoverTimer = useRef(null); // The dock consumes only name + turn_label per session, but the whole-dict identity changes on @@ -66,7 +66,7 @@ function DesktopDock({ [sessionsKey, cards, viewCards, browserCards, workflowCards, outputs], ); - const { dockRef, scrollRef, tile, gap, iconSize, scrolls, scrollHeight, bleed, applyMagnify } = useDockLayout({ + const { dockRef, scrollRef, tile, gap, step, iconSize, scrolls, scrollHeight, bleed, applyMagnify } = useDockLayout({ cardCount: entries.length, actionCount: DOCK_ACTION_COUNT, dividerCount: entries.length > 0 ? 2 : 1, @@ -99,13 +99,14 @@ function DesktopDock({ const readEdges = useCallback((el: HTMLDivElement) => { const top = el.scrollTop > 1; const bottom = el.scrollTop + el.clientHeight < el.scrollHeight - 1; - setEdges((prev) => (prev.top === top && prev.bottom === bottom ? prev : { top, bottom })); - }, []); + const { above, below } = hiddenCounts(el.scrollTop, el.clientHeight - bleed * 2, el.scrollHeight - bleed * 2, step); + setEdges((prev) => (prev.top === top && prev.bottom === bottom && prev.above === above && prev.below === below ? prev : { top, bottom, above, below })); + }, [bleed, step]); useLayoutEffect(() => { const el = scrollRef.current; if (el && scrolls) readEdges(el); - else setEdges((prev) => (prev.top || prev.bottom ? { top: false, bottom: false } : prev)); + else setEdges((prev) => (prev.top || prev.bottom ? { top: false, bottom: false, above: 0, below: 0 } : prev)); }, [scrolls, scrollHeight, entries.length, readEdges, scrollRef]); // The fade is exactly the bleed band, which (since scrolling only happens at the tile floor, where bleed > tile) @@ -119,10 +120,15 @@ function DesktopDock({ ? (liveShot?.id === hoveredEntry.id ? liveShot.dataUrl : hoveredEntry.thumbnail || undefined) : undefined; - // Past the shrink floor the column scrolls, and a hidden scrollbar with no caret reads as "the rest is gone". - const carets: { key: string; top: number; icon: React.ReactNode }[] = []; - if (scrolls && edges.top) carets.push({ key: 'up', top: 0, icon: }); - if (scrolls && edges.bottom) carets.push({ key: 'down', top: scrollHeight - CARET_H, icon: }); + // Past the shrink floor the column scrolls, and a hidden scrollbar with a faded last tile read as "cut off" + // (Eric, 2026-09-03). Each edge now says how many tiles are past it, and a click pages that way. + const pageBy = (direction: 1 | -1) => { + const el = scrollRef.current; + if (el) el.scrollBy({ top: direction * Math.max(step, el.clientHeight - bleed * 2 - step), behavior: 'smooth' }); + }; + const carets: { key: string; top: number; count: number; icon: React.ReactNode; onClick: () => void }[] = []; + if (scrolls && edges.top) carets.push({ key: 'up', top: 0, count: edges.above, icon: , onClick: () => pageBy(-1) }); + if (scrolls && edges.bottom) carets.push({ key: 'down', top: scrollHeight - CARET_H, count: edges.below, icon: , onClick: () => pageBy(1) }); return ( ( { e.stopPropagation(); endHover(); c.onClick(); }} + onMouseEnter={endHover} sx={{ position: 'absolute', left: 0, @@ -252,11 +269,17 @@ function DesktopDock({ display: 'flex', alignItems: 'center', justifyContent: 'center', - color: 'rgba(255,255,255,0.72)', - pointerEvents: 'none', + gap: '1px', + fontSize: '0.5625rem', + fontWeight: 700, + lineHeight: 1, + color: 'rgba(255,255,255,0.78)', + cursor: 'pointer', zIndex: 40, + '&:hover': { color: '#fff' }, }} > + {c.count > 0 ? {c.count} : null} {c.icon} ))} diff --git a/frontend/src/app/pages/Dashboard/desktop/dockOverflow.test.ts b/frontend/src/app/pages/Dashboard/desktop/dockOverflow.test.ts new file mode 100644 index 00000000..1a77cf52 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/dockOverflow.test.ts @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { hiddenCounts, TILE_MIN } from './useDockLayout'; + +// Sixty chats shrank the rail to 14px glyphs and the faded last tile read as "cut off hella" (Eric, +// 2026-09-03). The floor is legible, a wheel settles on whole tiles, and each edge says how many +// tiles are past it and pages that way on click. + +test('the tile floor is legible', () => { + assert.ok(TILE_MIN >= 20, `floor ${TILE_MIN}`); +}); + +test('hidden counts are whole tiles above and below the clip box', () => { + const step = 26; + assert.deepEqual(hiddenCounts(0, 520, 1560, step), { above: 0, below: 40 }); + assert.deepEqual(hiddenCounts(520, 520, 1560, step), { above: 20, below: 20 }); + assert.deepEqual(hiddenCounts(1040, 520, 1560, step), { above: 40, below: 0 }); + assert.deepEqual(hiddenCounts(0, 520, 400, step), { above: 0, below: 0 }); +}); + +test('the column snaps to tiles and the edge chips carry the count and a click', () => { + const src = fs.readFileSync(path.join(process.cwd(), 'src/app/pages/Dashboard/desktop/DesktopDock.tsx'), 'utf8'); + assert.match(src, /scrollSnapType: 'y proximity'/); + assert.match(src, /scrollSnapAlign: 'start'/); + assert.match(src, /data-dock-hidden=\{c\.count\}/); + assert.match(src, /el\.scrollBy\(\{ top: direction/); + assert.doesNotMatch(src, /pointerEvents: 'none',\s*zIndex: 40/, 'the edge chip must be clickable'); +}); diff --git a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.test.ts b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.test.ts index a274dde9..90abe07e 100644 --- a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.test.ts +++ b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.test.ts @@ -1,9 +1,10 @@ -// ENG-331: at 40+ chats the rail runs at the 14px floor, where the bell curve is at its widest +// ENG-331: past the floor the bell curve is at its widest; the floor moved 14 -> 20 on 2026-09-03 (60 chats were unreadable) // relative to the tile; these pin that the curve never crosses the entries/actions divider. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { computeMagnifyTransforms } from './useDockLayout'; +// The curve math is exercised at the old 14px floor on purpose: the numbers below were tuned there and the function is pure. const TILE = 14; const STEP = 18; const ENTRIES = 40; diff --git a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts index 3d048d5c..e12a2c0f 100644 --- a/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts +++ b/frontend/src/app/pages/Dashboard/desktop/useDockLayout.ts @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; const TILE_MAX = 30; -// Apple's floor is deliberately tiny: magnification, not tile size, is what keeps a small tile hittable. -const TILE_MIN = 14; +// Apple's floor is tinier, but a 14px chat glyph was unreadable at 60 chats (Eric, 2026-09-03); past this the column scrolls. +export const TILE_MIN = 20; const ROOT_PAD = 7; const GAP_RATIO = 0.3; const GAP_MIN = 3; @@ -25,6 +25,7 @@ export interface DockLayout { scrollRef: React.MutableRefObject; tile: number; gap: number; + step: number; iconSize: number; scrolls: boolean; scrollHeight: number; @@ -32,6 +33,14 @@ export interface DockLayout { applyMagnify: (clientY: number | null) => void; } +/** How many whole tiles sit above and below the clip box at this scroll position. */ +export function hiddenCounts(scrollTop: number, clientHeight: number, scrollHeight: number, step: number): { above: number; below: number } { + if (step <= 0 || scrollHeight <= clientHeight) return { above: 0, below: 0 }; + const above = Math.max(0, Math.round(scrollTop / step)); + const below = Math.max(0, Math.round((scrollHeight - scrollTop - clientHeight) / step)); + return { above, below }; +} + function gapFor(tile: number): number { return Math.max(GAP_MIN, Math.round(tile * GAP_RATIO)); } @@ -209,6 +218,7 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo scrollRef, tile, gap, + step, iconSize: Math.round(tile * ICON_RATIO), scrolls, scrollHeight,