[eric] dock: shrink-to-fit down to a 14px floor, magnify to an absolute size, and pin the rail's ends like the macOS Dock

This commit is contained in:
ciregenz
2026-07-31 02:02:56 -07:00
parent 1fee20f425
commit 98fa900dbf
3 changed files with 152 additions and 49 deletions
@@ -1,9 +1,10 @@
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import LanguageIcon from '@mui/icons-material/Language';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import KeyboardArrowUpRoundedIcon from '@mui/icons-material/KeyboardArrowUpRounded';
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import SettingsIcon from '@mui/icons-material/Settings';
import AppsRoundedIcon from '@mui/icons-material/AppsRounded';
@@ -15,6 +16,7 @@ import { openCardContextMenu } from './openCardContextMenu';
import { dockTileMenuRows } from './dockTileMenuRows';
import { useDockLayout } from './useDockLayout';
import { DockTileIcon } from './DockTileIcon';
import DockHoverPreview from './DockHoverPreview';
import type { AgentSession } from '@/shared/state/agentsSlice';
import type {
CardPosition,
@@ -37,9 +39,8 @@ interface DesktopDockProps {
onAddBrowser: () => void;
}
const PREVIEW_W = 190;
const ACTION_COUNT = 4;
const FADE = 18;
const CARET_H = 13;
/** Left-edge desktop dock: one tile per open card, hover previews, click focuses the window. */
function DesktopDock({
@@ -111,9 +112,10 @@ function DesktopDock({
else setEdges((prev) => (prev.top || prev.bottom ? { top: false, bottom: false } : prev));
}, [scrolls, scrollHeight, entries.length, readEdges, scrollRef]);
// Only fade the end that still has tiles behind it, so a magnified first or last tile stays crisp.
// The fade is exactly the bleed band, which (since scrolling only happens at the tile floor, where bleed > tile)
// is the only place a partly-scrolled tile can ever show: so a cut icon always fades out, never hard-clips.
const mask = scrolls
? `linear-gradient(to bottom, rgba(0,0,0,${edges.top ? 0 : 1}) 0px, #000 ${FADE}px, #000 calc(100% - ${FADE}px), rgba(0,0,0,${edges.bottom ? 0 : 1}) 100%)`
? `linear-gradient(to bottom, rgba(0,0,0,${edges.top ? 0 : 1}) 0px, #000 ${bleed}px, #000 calc(100% - ${bleed}px), rgba(0,0,0,${edges.bottom ? 0 : 1}) 100%)`
: undefined;
const hoveredEntry = hovered ? entries.find((e) => e.id === hovered.id) : undefined;
@@ -121,6 +123,11 @@ 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: <KeyboardArrowUpRoundedIcon sx={{ fontSize: '0.75rem' }} /> });
if (scrolls && edges.bottom) carets.push({ key: 'down', top: scrollHeight - CARET_H, icon: <KeyboardArrowDownRoundedIcon sx={{ fontSize: '0.75rem' }} /> });
return (
<Box
ref={dockRef}
@@ -148,6 +155,7 @@ function DesktopDock({
'& .osw-dock-tile': {
borderRadius: '12px',
transition: 'transform 0.12s ease-out',
transformOrigin: 'left center',
willChange: 'transform',
},
// One source of truth for glyph size, so favicons and every icon pack shrink with the tile.
@@ -157,6 +165,7 @@ function DesktopDock({
{entries.length > 0 && (
<Box
ref={scrollRef}
data-dock-scroll
onScroll={scrolls ? (e: React.UIEvent<HTMLDivElement>) => { readEdges(e.currentTarget); endHover(); } : undefined}
// The canvas zooms on wheel; a wheel we consume here must never reach it.
onWheel={scrolls ? (e: React.WheelEvent) => e.stopPropagation() : undefined}
@@ -260,36 +269,29 @@ function DesktopDock({
</React.Fragment>
))}
{hoveredEntry && (
{/* Anchored to the root's padding box, whose top edge IS the scroll box's top edge. */}
{carets.map((c) => (
<Box
key={c.key}
sx={{
position: 'absolute',
left: 'calc(100% + 10px)',
top: Math.max(0, hovered!.top - 34),
width: PREVIEW_W,
borderRadius: '10px',
overflow: 'hidden',
background: previewImage ? '#fff' : 'rgba(22,12,34,0.9)',
boxShadow: '0 12px 32px rgba(0,0,0,0.4)',
left: 0,
right: 0,
top: `${c.top}px`,
height: `${CARET_H}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'rgba(255,255,255,0.72)',
pointerEvents: 'none',
zIndex: 40,
}}
>
{previewImage ? (
<Box component="img" src={previewImage} alt="" sx={{ width: '100%', display: 'block' }} />
) : (
<Box sx={{ p: 1.25 }}>
<Typography sx={{ color: '#fff', fontSize: '0.75rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{hoveredEntry.label}
</Typography>
{hoveredEntry.snippet && (
<Typography sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.6875rem', mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{hoveredEntry.snippet}
</Typography>
)}
</Box>
)}
{c.icon}
</Box>
)}
))}
{hoveredEntry && <DockHoverPreview entry={hoveredEntry} top={hovered!.top} image={previewImage} />}
</Box>
);
}
@@ -0,0 +1,48 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import type { DockEntry } from './dockEntries';
const PREVIEW_W = 190;
interface DockHoverPreviewProps {
entry: DockEntry;
top: number;
image?: string;
}
/** The card that floats beside a hovered dock tile: a live shot when we have one, title + snippet otherwise. */
function DockHoverPreview({ entry, top, image }: DockHoverPreviewProps): React.ReactElement {
return (
<Box
sx={{
position: 'absolute',
left: 'calc(100% + 10px)',
top: Math.max(0, top - 34),
width: PREVIEW_W,
borderRadius: '10px',
overflow: 'hidden',
background: image ? '#fff' : 'rgba(22,12,34,0.9)',
boxShadow: '0 12px 32px rgba(0,0,0,0.4)',
pointerEvents: 'none',
}}
>
{image ? (
<Box component="img" src={image} alt="" sx={{ width: '100%', display: 'block' }} />
) : (
<Box sx={{ p: 1.25 }}>
<Typography sx={{ color: '#fff', fontSize: '0.75rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{entry.label}
</Typography>
{entry.snippet && (
<Typography sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.6875rem', mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</Typography>
)}
</Box>
)}
</Box>
);
}
export default DockHoverPreview;
@@ -1,16 +1,16 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
const TILE_MAX = 30;
const TILE_MIN = 18;
// Apple's floor is deliberately tiny: magnification, not tile size, is what keeps a small tile hittable.
const TILE_MIN = 14;
const ROOT_PAD = 7;
const GAP_RATIO = 0.3;
const GAP_MIN = 3;
const ICON_RATIO = 0.58;
// Breathing room above and below the dock; the canvas root clips overflow, so this is also the magnify headroom.
const EDGE_MARGIN = 16;
// Slack inside the scroll box so a magnified tile grows past the column without the scroll clip cutting it.
const BLEED = 14;
const BOOST = 0.5;
// macOS magnifies to an ABSOLUTE size, not a fixed ratio, so a 14px tile still grows to something you can hit.
const MAGNIFY_TARGET = 44;
// The bell curve was hand-tuned as 44px against a 30px tile; keep that ratio so it narrows as tiles shrink.
const FALLOFF_RATIO = 44 / 30;
@@ -41,6 +41,26 @@ function columnHeight(tile: number, tiles: number, dividers: number): number {
return ROOT_PAD * 2 + tiles * tile + dividers + gaps * gapFor(tile);
}
interface DockGeom {
els: HTMLElement[];
bases: number[];
rootTop: number;
written: string[];
}
function beginGesture(root: HTMLElement, box: HTMLDivElement | null): DockGeom {
const els = Array.from(root.querySelectorAll<HTMLElement>('.osw-dock-tile'));
const boxShift = box ? box.offsetTop - box.scrollTop : 0;
// The curve already tracks the cursor frame by frame; easing every tile on top of that is a tax, not motion.
els.forEach((t) => { t.style.transition = 'none'; });
return {
els,
bases: els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2),
rootTop: root.getBoundingClientRect().top,
written: els.map(() => ''),
};
}
/** macOS Dock sizing: tiles shrink to fit the column and only scroll once they hit the floor. */
export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayoutInput): DockLayout {
const dockRef = useRef<HTMLDivElement | null>(null);
@@ -67,41 +87,74 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
while (tile > TILE_MIN && columnHeight(tile, tileCount, dividerCount) > budget) tile -= 1;
const gap = gapFor(tile);
const scrolls = columnHeight(tile, tileCount, dividerCount) > budget;
// Slack inside the scroll box so a magnified tile grows past the column without the scroll clip cutting it.
const bleed = Math.ceil((MAGNIFY_TARGET - tile) / 2) + 2;
// Pinned rows keep their full height; whatever is left is what the card column may occupy.
const pinned = ROOT_PAD * 2 + dividerCount + actionCount * tile + (dividerCount + actionCount) * gap;
const scrollHeight = Math.max(tile * 3 + gap * 2 + BLEED * 2, budget - pinned);
const step = tile + gap;
// Whole tile steps only, so the clip edge always lands in a gap instead of bisecting an icon.
const rows = Math.max(1, Math.floor((budget - pinned - bleed * 2 + gap) / step));
const scrollHeight = rows * step - gap + bleed * 2;
const tileRef = useRef(tile);
tileRef.current = tile;
const geomRef = useRef<DockGeom | null>(null);
const dropGeom = useCallback((): void => { geomRef.current = null; }, []);
// Tiles cannot move mid-hover, so re-measuring them per mousemove was pure style-recalc tax; these are
// every signal that CAN move them.
useEffect(dropGeom, [dropGeom, tile, gap, scrollHeight, scrolls, containerH, cardCount, actionCount]);
useEffect(() => {
const box = scrollRef.current;
if (!box) return undefined;
box.addEventListener('scroll', dropGeom, { passive: true });
return () => box.removeEventListener('scroll', dropGeom);
}, [dropGeom, scrolls, cardCount]);
// macOS Dock magnification: the tile under the cursor grows on a bell curve and its neighbors SLIDE
// AWAY to make room. Each tile that grows by `extra` pushes every tile past it by extra/2.
const applyMagnify = useCallback((clientY: number | null) => {
const root = dockRef.current;
if (!root) return;
const els = Array.from(root.querySelectorAll<HTMLElement>('.osw-dock-tile'));
if (els.length === 0) return;
if (clientY == null) {
els.forEach((t) => { t.style.transform = ''; t.style.zIndex = ''; });
// Handing the transition back before clearing the transform is what makes the settle glide instead of snap.
root.querySelectorAll<HTMLElement>('.osw-dock-tile').forEach((t) => {
t.style.transition = '';
t.style.transform = '';
t.style.zIndex = '';
});
geomRef.current = null;
return;
}
const geom = geomRef.current ?? beginGesture(root, scrollRef.current);
geomRef.current = geom;
const { els, bases } = geom;
if (els.length === 0) return;
const size = tileRef.current;
const boost = MAGNIFY_TARGET / size - 1;
const falloff = size * FALLOFF_RATIO;
const box = scrollRef.current;
const boxShift = box ? box.offsetTop - box.scrollTop : 0;
const cy = clientY - root.getBoundingClientRect().top;
const bases = els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2);
const scales = bases.map((b) => 1 + BOOST * Math.exp(-(((cy - b) / falloff) ** 2)));
const cy = clientY - geom.rootTop;
const scales = bases.map((b) => 1 + boost * Math.exp(-(((cy - b) / falloff) ** 2)));
const extra = scales.map((s) => size * (s - 1));
const total = extra.reduce((a, b) => a + b, 0);
// Bases run in DOM order, top to bottom, so "everything before me" is a running sum, not an inner loop.
const head = (extra[0] - total) / 2;
const tail = (total - extra[els.length - 1]) / 2;
const span = bases[els.length - 1] - bases[0];
// Apple's Dock never grows longer than its rail: pin both ends and let the spread squeeze the middle.
const slope = span > 0 ? (tail - head) / span : 0;
let before = 0;
els.forEach((t, i) => {
let shift = 0;
for (let j = 0; j < els.length; j++) {
if (j === i) continue;
shift += (extra[j] / 2) * Math.sign(bases[i] - bases[j]);
}
t.style.transform = `translateY(${shift.toFixed(1)}px) scale(${scales[i].toFixed(3)})`;
t.style.transformOrigin = 'left center';
const raw = before - (total - before - extra[i]);
const shift = raw / 2 - head - slope * (bases[i] - bases[0]);
before += extra[i];
const next = `translateY(${shift.toFixed(1)}px) scale(${scales[i].toFixed(3)})`;
// Tiles outside the bell curve land on the same transform move after move, and a no-op style write is not free.
if (geom.written[i] === next) return;
geom.written[i] = next;
t.style.transform = next;
t.style.zIndex = String(10 + Math.round((scales[i] - 1) * 100));
});
}, []);
@@ -114,7 +167,7 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
iconSize: Math.round(tile * ICON_RATIO),
scrolls,
scrollHeight,
bleed: BLEED,
bleed,
applyMagnify,
};
}