mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 20:57:42 +02:00
[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:
@@ -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));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user