[eric] canvas: dock magnify stops at the divider (hovering Browsers no longer inflates chats) and low hover previews grow upward (ENG-331)

This commit is contained in:
ciregenz
2026-08-17 15:54:37 -07:00
parent e0d0d8c600
commit d2f3adaf9a
5 changed files with 108 additions and 21 deletions
@@ -192,6 +192,7 @@ function DesktopDock({
<Box
key={entry.id}
className="osw-dock-tile"
data-dock-group="entries"
role="button"
// The hover card carries the name for the eye; this carries it for everything else (screen readers, tests).
aria-label={entry.label}
@@ -260,7 +261,9 @@ function DesktopDock({
</Box>
))}
{hoveredEntry && <DockHoverPreview entry={hoveredEntry} top={hovered!.top} image={previewImage} />}
{hoveredEntry && (
<DockHoverPreview entry={hoveredEntry} top={hovered!.top} railHeight={dockRef.current?.offsetHeight ?? 0} image={previewImage} />
)}
</Box>
);
}
@@ -36,6 +36,7 @@ function DockActionTiles({ tile, onAddBrowser, onApplications, onHoverAway }: Do
<Tooltip title={a.label} placement="right">
<Box
className="osw-dock-tile"
data-dock-group="actions"
onClick={a.act}
onMouseEnter={onHoverAway}
sx={{
@@ -8,17 +8,22 @@ const PREVIEW_W = 190;
interface DockHoverPreviewProps {
entry: DockEntry;
top: number;
railHeight: 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 {
function DockHoverPreview({ entry, top, railHeight, image }: DockHoverPreviewProps): React.ReactElement {
// A low tile's preview used to hang past the rail bottom into the canvas clip and get cut (ENG-331); the lower half anchors from the bottom and grows upward instead.
const fromBottom = railHeight > 0 && top > railHeight / 2;
return (
<Box
sx={{
position: 'absolute',
left: 'calc(100% + 10px)',
top: Math.max(0, top - 34),
...(fromBottom
? { bottom: Math.max(0, railHeight - top - 44) }
: { top: Math.max(0, top - 34) }),
width: PREVIEW_W,
borderRadius: '10px',
overflow: 'hidden',
@@ -0,0 +1,48 @@
// ENG-331: at 40+ chats the rail runs at the 14px floor, where the bell curve is at its widest
// 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';
const TILE = 14;
const STEP = 18;
const ENTRIES = 40;
const bases: number[] = [];
const groups: string[] = [];
for (let i = 0; i < ENTRIES; i += 1) {
bases.push(7 + i * STEP + TILE / 2);
groups.push('entries');
}
const actionsTop = 7 + ENTRIES * STEP + 9;
for (let i = 0; i < 5; i += 1) {
bases.push(actionsTop + i * STEP + TILE / 2);
groups.push('actions');
}
test('hovering an action button never inflates the neighboring chat entry', () => {
const cy = bases[ENTRIES];
const { transforms, scales } = computeMagnifyTransforms(bases, groups, cy, TILE);
for (let i = 0; i < ENTRIES; i += 1) {
assert.equal(scales[i], 1, `entry ${i} scaled to ${scales[i]}`);
assert.equal(transforms[i], '');
}
assert.ok(scales[ENTRIES] > 2, `hovered action should magnify, got ${scales[ENTRIES]}`);
});
test('hovering the last chat entry never inflates the action buttons below the divider', () => {
const cy = bases[ENTRIES - 1];
const { transforms, scales } = computeMagnifyTransforms(bases, groups, cy, TILE);
for (let i = ENTRIES; i < bases.length; i += 1) {
assert.equal(scales[i], 1, `action ${i - ENTRIES} scaled to ${scales[i]}`);
assert.equal(transforms[i], '');
}
assert.ok(scales[ENTRIES - 1] > 2, 'hovered entry should magnify');
assert.ok(scales[ENTRIES - 2] > 1, 'in-group neighbor keeps the macOS curve');
});
test('the curve itself still works mid-rail (no regression from the group mask)', () => {
const cy = bases[20];
const { scales } = computeMagnifyTransforms(bases, groups, cy, TILE);
assert.ok(scales[20] > 2.5, 'center tile approaches the 44px target');
assert.ok(scales[19] > scales[17], 'falloff is monotone toward the cursor');
});
@@ -44,6 +44,7 @@ function columnHeight(tile: number, tiles: number, dividers: number): number {
interface DockGeom {
els: HTMLElement[];
bases: number[];
groups: string[];
rootTop: number;
written: string[];
}
@@ -56,11 +57,50 @@ function beginGesture(root: HTMLElement, box: HTMLDivElement | null): DockGeom {
return {
els,
bases: els.map((t) => (box?.contains(t) ? boxShift : 0) + t.offsetTop + t.offsetHeight / 2),
groups: els.map((t) => t.dataset.dockGroup || ''),
rootTop: root.getBoundingClientRect().top,
written: els.map(() => ''),
};
}
/** The dock's bell-curve magnify as pure math. The rail holds two species (chat entries above the
* divider, action buttons below) and the curve must never cross that divider: hovering Browsers
* used to inflate the neighboring chat (ENG-331). Tiles outside the cursor's group get '' (reset). */
export function computeMagnifyTransforms(
bases: number[], groups: string[], cy: number, size: number,
): { transforms: string[]; scales: number[] } {
const transforms: string[] = bases.map(() => '');
const scales: number[] = bases.map(() => 1);
if (bases.length === 0) return { transforms, scales };
const boost = MAGNIFY_TARGET / size - 1;
const falloff = size * FALLOFF_RATIO;
let nearest = 0;
for (let i = 1; i < bases.length; i += 1) {
if (Math.abs(cy - bases[i]) < Math.abs(cy - bases[nearest])) nearest = i;
}
const activeGroup = groups[nearest];
const idx: number[] = [];
groups.forEach((g, i) => { if (g === activeGroup) idx.push(i); });
const sub = idx.map((i) => 1 + boost * Math.exp(-(((cy - bases[i]) / falloff) ** 2)));
const extra = sub.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[extra.length - 1]) / 2;
const span = bases[idx[idx.length - 1]] - bases[idx[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;
idx.forEach((elI, k) => {
const raw = before - (total - before - extra[k]);
const shift = raw / 2 - head - slope * (bases[elI] - bases[idx[0]]);
before += extra[k];
transforms[elI] = `translateY(${shift.toFixed(1)}px) scale(${sub[k].toFixed(3)})`;
scales[elI] = sub[k];
});
return { transforms, scales };
}
/** 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);
@@ -145,30 +185,20 @@ export function useDockLayout({ cardCount, actionCount, dividerCount }: DockLayo
}
const geom = geomRef.current ?? beginGesture(root, scrollRef.current);
geomRef.current = geom;
const { els, bases } = geom;
const { els, bases, groups } = geom;
if (els.length === 0) return;
const size = tileRef.current;
const boost = MAGNIFY_TARGET / size - 1;
const falloff = size * FALLOFF_RATIO;
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;
const { transforms, scales } = computeMagnifyTransforms(bases, groups, cy, tileRef.current);
els.forEach((t, i) => {
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)})`;
const next = transforms[i];
// 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;
if (next === '') {
t.style.transform = '';
t.style.zIndex = '';
return;
}
t.style.transform = next;
t.style.zIndex = String(10 + Math.round((scales[i] - 1) * 100));
});